├── .nvmrc ├── .prettierignore ├── src ├── rules │ ├── __tests__ │ │ ├── fixtures │ │ │ ├── file.ts │ │ │ ├── File.tsx │ │ │ └── tsconfig.json │ │ ├── no-deprecated-entrypoints.test.js │ │ ├── a11y-no-title-usage.test.js │ │ ├── a11y-remove-disable-tooltip.test.js │ │ ├── a11y-explicit-heading.test.js │ │ ├── no-deprecated-experimental-components.test.js │ │ ├── enforce-css-module-default-import.js │ │ ├── use-deprecated-from-deprecated.test.js │ │ ├── enforce-button-for-link-with-no-href.test.js │ │ ├── prefer-action-list-item-onselect.test.js │ │ ├── a11y-use-accessible-tooltip.test.js │ │ ├── a11y-no-duplicate-form-labels.test.js │ │ ├── enforce-css-module-identifier-casing.test.js │ │ ├── no-use-responsive-value.test.js │ │ ├── spread-props-first.test.js │ │ ├── a11y-link-in-text-block.test.js │ │ ├── no-deprecated-props.test.js │ │ ├── direct-slot-children.test.js │ │ ├── a11y-tooltip-interactive-trigger.test.js │ │ └── no-system-props.test.js │ ├── no-deprecated-entrypoints.js │ ├── a11y-no-title-usage.js │ ├── enforce-button-for-link-with-no-href.js │ ├── a11y-remove-disable-tooltip.js │ ├── no-use-responsive-value.js │ ├── no-deprecated-experimental-components.js │ ├── enforce-css-module-default-import.js │ ├── a11y-explicit-heading.js │ ├── no-deprecated-props.js │ ├── a11y-no-duplicate-form-labels.js │ ├── spread-props-first.js │ ├── prefer-action-list-item-onselect.js │ ├── enforce-css-module-identifier-casing.js │ ├── direct-slot-children.js │ ├── use-deprecated-from-deprecated.js │ ├── new-color-css-vars.js │ ├── a11y-link-in-text-block.js │ ├── a11y-use-accessible-tooltip.js │ ├── a11y-tooltip-interactive-trigger.js │ └── no-unnecessary-components.js ├── url.js ├── utils │ ├── get-jsx-opening-element-attribute.js │ ├── is-html-element.js │ ├── get-jsx-opening-element-name.js │ ├── get-variable-declaration.js │ ├── is-imported-from.js │ ├── flatten-components.js │ ├── casing-matches.js │ ├── css-modules.js │ ├── is-primer-component.js │ └── __tests__ │ │ └── flatten-components.test.js ├── configs │ ├── components.js │ └── recommended.js └── index.js ├── .github ├── CODEOWNERS ├── dependabot.yml ├── workflows │ ├── release_tracking.yml │ ├── check-for-changeset.yml │ ├── ci.yml │ ├── add-to-inbox.yml │ └── release.yml └── copilot-instructions.md ├── .changeset ├── dark-jobs-type.md ├── tough-parts-sin.md ├── config.json └── README.md ├── jest.config.js ├── docs └── rules │ ├── no-wildcard-imports.md │ ├── use-deprecated-from-deprecated.md │ ├── no-deprecated-entrypoints.md │ ├── no-deprecated-experimental-components.md │ ├── a11y-remove-disable-tooltip.md │ ├── new-color-css-vars.md │ ├── enforce-css-module-identifier-casing.md │ ├── no-deprecated-props.md │ ├── a11y-no-title-usage.md │ ├── a11y-explicit-heading.md │ ├── a11y-tooltip-interactive-trigger.md │ ├── enforce-button-for-link-with-no-href.md │ ├── prefer-action-list-item-onselect.md │ ├── a11y-no-duplicate-form-labels.md │ ├── direct-slot-children.md │ ├── a11y-use-accessible-tooltip.md │ ├── spread-props-first.md │ ├── enforce-css-module-default-import.md │ ├── no-unnecessary-components.md │ ├── no-system-props.md │ ├── no-use-responsive-value.md │ ├── a11y-link-in-text-block.md │ └── use-styled-react-import.md ├── .markdownlint-cli2.cjs ├── LICENSE ├── eslint.config.js ├── .gitignore ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .changeset 2 | node_modules 3 | CHANGELOG.md 4 | -------------------------------------------------------------------------------- /src/rules/__tests__/fixtures/file.ts: -------------------------------------------------------------------------------- 1 | // https://typescript-eslint.io/packages/rule-tester/#type-aware-testing 2 | -------------------------------------------------------------------------------- /src/rules/__tests__/fixtures/File.tsx: -------------------------------------------------------------------------------- 1 | // https://typescript-eslint.io/packages/rule-tester/#type-aware-testing 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # All changes should be reviewed by a member of the @react-reviewers team 2 | * @primer/engineer-reviewers 3 | -------------------------------------------------------------------------------- /.changeset/dark-jobs-type.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'eslint-plugin-primer-react': patch 3 | --- 4 | 5 | Fix `no-unnecessary-components` rule to support `@primer/styled-react` imports. 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('jest').Config} **/ 4 | module.exports = { 5 | testEnvironment: 'node', 6 | testMatch: ['**/__tests__/*.test.js'], 7 | } 8 | -------------------------------------------------------------------------------- /.changeset/tough-parts-sin.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'eslint-plugin-primer-react': patch 3 | --- 4 | 5 | Export `enforce-button-for-link-with-no-href` rule, which flags `Link` components which don't have a `href`. 6 | -------------------------------------------------------------------------------- /src/rules/__tests__/fixtures/tsconfig.json: -------------------------------------------------------------------------------- 1 | // https://typescript-eslint.io/packages/rule-tester/#type-aware-testing 2 | { 3 | "compilerOptions": { 4 | "strict": true 5 | }, 6 | "include": ["file.ts", "File.tsx"] 7 | } 8 | -------------------------------------------------------------------------------- /src/url.js: -------------------------------------------------------------------------------- 1 | const {homepage, version} = require('../package.json') 2 | const path = require('path') 3 | 4 | module.exports = ({id}) => { 5 | const url = new URL(homepage) 6 | const rule = path.basename(id, '.js') 7 | url.hash = '' 8 | url.pathname += `/blob/v${version}/docs/rules/${rule}.md` 9 | return url.toString() 10 | } 11 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", {"repo": "primer/eslint-plugin-primer-react"}], 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "main", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/get-jsx-opening-element-attribute.js: -------------------------------------------------------------------------------- 1 | function getJSXOpeningElementAttribute(openingEl, name) { 2 | const attributes = openingEl.attributes 3 | const attribute = attributes.find(attribute => { 4 | return attribute.name && attribute.name.name === name 5 | }) 6 | 7 | return attribute 8 | } 9 | 10 | exports.getJSXOpeningElementAttribute = getJSXOpeningElementAttribute 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | labels: 8 | - 'dependencies' 9 | - 'skip changeset' 10 | - package-ecosystem: npm 11 | directory: '/' 12 | schedule: 13 | interval: weekly 14 | labels: 15 | - 'dependencies' 16 | - 'skip changeset' 17 | -------------------------------------------------------------------------------- /src/utils/is-html-element.js: -------------------------------------------------------------------------------- 1 | function isHTMLElement(jsxNode) { 2 | if (jsxNode.name.type === 'JSXIdentifier') { 3 | // this is a very silly proxy, but it works 4 | // React components are capitalised, html elements are not 5 | const firstLetter = jsxNode.name.name 6 | if (firstLetter === firstLetter.toLowerCase()) return true 7 | } 8 | 9 | return false 10 | } 11 | exports.isHTMLElement = isHTMLElement 12 | -------------------------------------------------------------------------------- /src/utils/get-jsx-opening-element-name.js: -------------------------------------------------------------------------------- 1 | /** Convert JSXOpeningElement name to string */ 2 | function getJSXOpeningElementName(jsxNode) { 3 | if (jsxNode.name.type === 'JSXIdentifier') { 4 | return jsxNode.name.name 5 | } else if (jsxNode.name.type === 'JSXMemberExpression') { 6 | return `${jsxNode.name.object.name}.${jsxNode.name.property.name}` 7 | } 8 | } 9 | 10 | exports.getJSXOpeningElementName = getJSXOpeningElementName 11 | -------------------------------------------------------------------------------- /.github/workflows/release_tracking.yml: -------------------------------------------------------------------------------- 1 | name: Release Event Tracking 2 | # Measure a datadog event every time a release occurs 3 | 4 | on: 5 | pull_request: 6 | types: 7 | - closed 8 | - opened 9 | - reopened 10 | 11 | release: 12 | types: [published] 13 | 14 | jobs: 15 | release-tracking: 16 | name: Release Tracking 17 | uses: primer/.github/.github/workflows/release_tracking.yml@v2.4.0 18 | secrets: 19 | datadog_api_key: ${{ secrets.DATADOG_API_KEY }} 20 | -------------------------------------------------------------------------------- /src/utils/get-variable-declaration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the variable declaration for the given identifier 3 | */ 4 | function getVariableDeclaration(scope, identifier) { 5 | if (scope === null) { 6 | return null 7 | } 8 | 9 | for (const variable of scope.variables) { 10 | if (variable.name === identifier.name) { 11 | return variable.defs[0] 12 | } 13 | } 14 | 15 | return getVariableDeclaration(scope.upper, identifier) 16 | } 17 | exports.getVariableDeclaration = getVariableDeclaration 18 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with 4 | multi-package repos, or single-package repos to help you version and publish your code. You can find the full 5 | documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/utils/is-imported-from.js: -------------------------------------------------------------------------------- 1 | const {getVariableDeclaration} = require('./get-variable-declaration') 2 | 3 | /** 4 | * Check if the given identifier is imported from the given module 5 | */ 6 | function isImportedFrom(moduleRegex, identifier, scope) { 7 | const definition = getVariableDeclaration(scope, identifier) 8 | 9 | // Return true if the variable was imported from the given module 10 | return definition && definition.type === 'ImportBinding' && moduleRegex.test(definition.parent.source.value) 11 | } 12 | exports.isImportedFrom = isImportedFrom 13 | -------------------------------------------------------------------------------- /src/utils/flatten-components.js: -------------------------------------------------------------------------------- 1 | function flattenComponents(componentObj) { 2 | let result = {} 3 | 4 | for (const key of Object.keys(componentObj)) { 5 | if (typeof componentObj[key] === 'object') { 6 | for (const item of Object.keys(componentObj[key])) { 7 | result = {...result, [`${key}${item !== 'self' ? `.${item}` : ''}`]: componentObj[key][item]} 8 | } 9 | } else { 10 | result = {...result, [key]: componentObj[key]} 11 | } 12 | } 13 | 14 | return result 15 | } 16 | 17 | exports.flattenComponents = flattenComponents 18 | -------------------------------------------------------------------------------- /src/utils/casing-matches.js: -------------------------------------------------------------------------------- 1 | const camelReg = /^[a-z]+(?:[A-Z0-9][a-z0-9]+)*?$/ 2 | const pascalReg = /^(?:[A-Z0-9][a-z0-9]+)+?$/ 3 | const kebabReg = /^[a-z]+(?:-[a-z0-9]+)*?$/ 4 | 5 | function casingMatches(name, type) { 6 | switch (type) { 7 | case 'camel': 8 | return camelReg.test(name) 9 | case 'pascal': 10 | return pascalReg.test(name) 11 | case 'kebab': 12 | return kebabReg.test(name) 13 | default: 14 | throw new Error(`Invalid case type ${type}`) 15 | } 16 | } 17 | exports.casingMatches = casingMatches 18 | 19 | exports.availableCasings = ['camel', 'pascal', 'kebab'] 20 | -------------------------------------------------------------------------------- /docs/rules/no-wildcard-imports.md: -------------------------------------------------------------------------------- 1 | # No Wildcard Imports 2 | 3 | ## Rule Details 4 | 5 | This rule enforces that no wildcard imports are used from `@primer/react`. 6 | 7 | 👎 Examples of **incorrect** code for this rule 8 | 9 | ```jsx 10 | import {Dialog} from '@primer/react/lib-esm/Dialog/Dialog' 11 | 12 | function ExampleComponent() { 13 | return {/* ... */} 14 | } 15 | ``` 16 | 17 | 👍 Examples of **correct** code for this rule: 18 | 19 | ```jsx 20 | import {Dialog} from '@primer/react/experimental' 21 | 22 | function ExampleComponent() { 23 | return {/* ... */} 24 | } 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/rules/use-deprecated-from-deprecated.md: -------------------------------------------------------------------------------- 1 | # Use Deprecated from Deprecated 2 | 3 | ## Rule Details 4 | 5 | This rule enforces the usage of deprecated imports from `@primer/react/deprecated`. 6 | 7 | 👎 Examples of **incorrect** code for this rule 8 | 9 | ```jsx 10 | import {Dialog} from '@primer/react' 11 | 12 | function ExampleComponent() { 13 | return {/* ... */} 14 | } 15 | ``` 16 | 17 | 👍 Examples of **correct** code for this rule: 18 | 19 | ```jsx 20 | import {Dialog} from '@primer/react/deprecated' 21 | 22 | function ExampleComponent() { 23 | return {/* ... */} 24 | } 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/rules/no-deprecated-entrypoints.md: -------------------------------------------------------------------------------- 1 | # No Deprecated Entrypoints 2 | 3 | ## Rule Details 4 | 5 | This rule enforces the usage of non-deprecated entrypoints from `@primer/react`. 6 | 7 | 👎 Examples of **incorrect** code for this rule 8 | 9 | ```jsx 10 | import {DataTable} from '@primer/react/drafts' 11 | 12 | function ExampleComponent() { 13 | return {/* ... */} 14 | } 15 | ``` 16 | 17 | 👍 Examples of **correct** code for this rule: 18 | 19 | ```jsx 20 | import {ExampleComponent} from '@primer/react/experimental' 21 | 22 | function ExampleComponent() { 23 | return {/* ... */} 24 | } 25 | ``` 26 | -------------------------------------------------------------------------------- /src/utils/css-modules.js: -------------------------------------------------------------------------------- 1 | function importBindingIsFromCSSModuleImport(node) { 2 | return node.type === 'ImportBinding' && node.parent?.source?.value?.endsWith('.module.css') 3 | } 4 | 5 | function identifierIsCSSModuleBinding(node, context) { 6 | if (node.type !== 'Identifier') return false 7 | const ref = context.sourceCode.getScope(node).references.find(reference => reference.identifier.name === node.name) 8 | if (ref && ref.resolved?.defs?.some(importBindingIsFromCSSModuleImport)) { 9 | return true 10 | } 11 | return false 12 | } 13 | 14 | exports.importBindingIsFromCSSModuleImport = importBindingIsFromCSSModuleImport 15 | exports.identifierIsCSSModuleBinding = identifierIsCSSModuleBinding 16 | -------------------------------------------------------------------------------- /src/utils/is-primer-component.js: -------------------------------------------------------------------------------- 1 | const {isImportedFrom} = require('./is-imported-from') 2 | 3 | /** 4 | * Check if `name` is a JSX component that is imported from `@primer/react`, 5 | * `@primer/styled-react`, or a subpath of either. 6 | * @returns {boolean} 7 | */ 8 | function isPrimerComponent(name, scope) { 9 | let identifier 10 | 11 | switch (name.type) { 12 | case 'JSXIdentifier': 13 | identifier = name 14 | break 15 | case 'JSXMemberExpression': 16 | identifier = name.object 17 | break 18 | default: 19 | return false 20 | } 21 | return isImportedFrom(/^@primer\/(?:styled-)?react(?:$|\/)/, identifier, scope) 22 | } 23 | exports.isPrimerComponent = isPrimerComponent 24 | -------------------------------------------------------------------------------- /docs/rules/no-deprecated-experimental-components.md: -------------------------------------------------------------------------------- 1 | # No deprecated experimental components 2 | 3 | ## Rule Details 4 | 5 | This rule discourages the usage of specific imports from `@primer/react/experimental`. 6 | 7 | 👎 Examples of **incorrect** code for this rule 8 | 9 | ```jsx 10 | import {SelectPanel} from '@primer/react/experimental' 11 | 12 | function ExampleComponent() { 13 | return 14 | } 15 | ``` 16 | 17 | 👍 Examples of **correct** code for this rule: 18 | 19 | You can satisfy the rule by either converting to the non-experimental version: 20 | 21 | ```jsx 22 | import {SelectPanel} from '@primer/react' 23 | 24 | function ExampleComponent() { 25 | return 26 | } 27 | ``` 28 | 29 | Or by removing usage of the component. 30 | -------------------------------------------------------------------------------- /.markdownlint-cli2.cjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const githubMarkdownOpinions = require('@github/markdownlint-github') 4 | 5 | const options = githubMarkdownOpinions.init({ 6 | // Rules we don't care to enforce (usually stylistic) 7 | 'line-length': false, 8 | 'blanks-around-headings': false, 9 | 'blanks-around-lists': false, 10 | 'no-trailing-spaces': false, 11 | 'no-multiple-blanks': false, 12 | 'no-trailing-punctuation': false, 13 | 'single-trailing-newline': false, 14 | 'ul-indent': false, 15 | 'ul-style': false, 16 | 'no-hard-tabs': false, 17 | 'first-line-heading': false, 18 | 'no-space-in-emphasis': false, 19 | 'blanks-around-fences': false, 20 | }) 21 | 22 | module.exports = { 23 | config: options, 24 | customRules: ['@github/markdownlint-github'], 25 | outputFormatters: [['markdownlint-cli2-formatter-pretty', {appendLink: true}]], 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/check-for-changeset.yml: -------------------------------------------------------------------------------- 1 | name: Check for changeset 2 | 3 | on: 4 | pull_request: 5 | types: 6 | # On by default if you specify no types. 7 | - 'opened' 8 | - 'reopened' 9 | - 'synchronize' 10 | # For `skip-label` only. 11 | - 'labeled' 12 | - 'unlabeled' 13 | 14 | jobs: 15 | check-for-changeset: 16 | name: Check for changeset 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | - name: 'Check for changeset' 21 | uses: brettcannon/check-for-changed-files@v1 22 | with: 23 | file-pattern: '.changeset/*.md' 24 | skip-label: 'skip changeset' 25 | failure-message: 'No changeset found. If these changes should not result in a new version, apply the ${skip-label} label to this pull request. If these changes should result in a version bump, please add a changeset https://git.io/J6QvQ' 26 | -------------------------------------------------------------------------------- /src/rules/__tests__/no-deprecated-entrypoints.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {RuleTester} = require('eslint') 4 | const rule = require('../no-deprecated-entrypoints') 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-entrypoints', rule, { 19 | valid: [`import {Box} from '@primer/react';`], 20 | invalid: [ 21 | { 22 | code: `import {DataTable} from '@primer/react/drafts';`, 23 | output: `import {DataTable} from '@primer/react/experimental';`, 24 | errors: [ 25 | { 26 | message: 27 | 'The drafts entrypoint is deprecated and will be removed in the next major release. Use the experimental entrypoint instead', 28 | }, 29 | ], 30 | }, 31 | ], 32 | }) 33 | -------------------------------------------------------------------------------- /src/configs/components.js: -------------------------------------------------------------------------------- 1 | const {flattenComponents} = require('../utils/flatten-components') 2 | 3 | const components = flattenComponents({ 4 | Button: 'button', 5 | IconButton: 'button', 6 | ToggleSwitch: 'button', 7 | Radio: 'input', 8 | Checkbox: 'input', 9 | Text: 'span', 10 | TextInput: { 11 | Action: 'button', 12 | self: 'input', 13 | }, 14 | Select: { 15 | Option: 'option', 16 | self: 'select', 17 | }, 18 | TabNav: { 19 | self: 'nav', 20 | }, 21 | }) 22 | 23 | // We want to avoid setting a jsx-a11y mapping from `Box` to `div` until polymorphic linting is enabled for jsx-a11y. 24 | // However, polymorphic linting is enabled for the github plugin, so we can safely map `Box` to `div` (while also having it properly interpret the `as` prop) 25 | const githubMapping = Object.assign({}, components) 26 | githubMapping['Box'] = 'div' 27 | 28 | module.exports = { 29 | jsxA11yMapping: components, 30 | githubMapping, 31 | } 32 | -------------------------------------------------------------------------------- /src/rules/__tests__/a11y-no-title-usage.test.js: -------------------------------------------------------------------------------- 1 | const rule = require('../a11y-no-title-usage') 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-no-title-usage', rule, { 17 | valid: [ 18 | ``, 19 | ``, 20 | ``, 21 | ], 22 | invalid: [ 23 | { 24 | code: ``, 25 | output: ``, 26 | errors: [{messageId: 'noTitleOnRelativeTime'}], 27 | }, 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /docs/rules/a11y-remove-disable-tooltip.md: -------------------------------------------------------------------------------- 1 | ## Rule Details 2 | 3 | This rule enforces to remove the `unsafeDisableTooltip` from `IconButton` component so that they have a tooltip by default. `unsafeDisableTooltip` prop is created for an incremental migration and should be removed once all icon buttons have a tooltip. 4 | 5 | 👎 Examples of **incorrect** code for this rule: 6 | 7 | ```jsx 8 | import {IconButton} from '@primer/react' 9 | 10 | const App = () => ( 11 | 12 | // OR 13 | 14 | // OR 15 | // This is incorrect because it should be removed 16 | ) 17 | ``` 18 | 19 | 👍 Examples of **correct** code for this rule: 20 | 21 | ```jsx 22 | import {IconButton} from '@primer/react' 23 | 24 | const App = () => 25 | ``` 26 | -------------------------------------------------------------------------------- /src/rules/no-deprecated-entrypoints.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('../url') 4 | 5 | /** 6 | * @type {import('eslint').Rule.RuleModule} 7 | */ 8 | module.exports = { 9 | meta: { 10 | type: 'problem', 11 | docs: { 12 | description: 'Avoid using deprecated entrypoints from @primer/react', 13 | recommended: true, 14 | url: url(module), 15 | }, 16 | fixable: true, 17 | schema: [], 18 | }, 19 | create(context) { 20 | return { 21 | ImportDeclaration(node) { 22 | if (node.source.value === '@primer/react/drafts') { 23 | context.report({ 24 | node, 25 | message: 26 | 'The drafts entrypoint is deprecated and will be removed in the next major release. Use the experimental entrypoint instead', 27 | fix(fixer) { 28 | return fixer.replaceText(node.source, `'@primer/react/experimental'`) 29 | }, 30 | }) 31 | } 32 | }, 33 | } 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | format: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - name: Use Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | cache: 'npm' 18 | - run: npm ci 19 | - run: npm run format:check 20 | 21 | test: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v6 25 | - name: Use Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: 'npm' 30 | - run: npm ci 31 | - run: npm test 32 | 33 | lint: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v6 37 | - name: Use Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 20 41 | cache: 'npm' 42 | - run: npm ci 43 | - run: npm run lint 44 | - run: npm run lint:md 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Primer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/rules/a11y-no-title-usage.js: -------------------------------------------------------------------------------- 1 | const url = require('../url') 2 | const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute') 3 | 4 | module.exports = { 5 | meta: { 6 | type: 'error', 7 | docs: { 8 | description: 'Disallow usage of title attribute on some components', 9 | recommended: true, 10 | url: url(module), 11 | }, 12 | messages: { 13 | noTitleOnRelativeTime: 'Avoid using the title attribute on RelativeTime.', 14 | }, 15 | fixable: 'code', 16 | }, 17 | 18 | create(context) { 19 | return { 20 | JSXOpeningElement(jsxNode) { 21 | const title = getJSXOpeningElementAttribute(jsxNode, 'noTitle') 22 | 23 | if (title && title.value && title.value.expression && title.value.expression.value !== true) { 24 | context.report({ 25 | node: title, 26 | messageId: 'noTitleOnRelativeTime', 27 | fix(fixer) { 28 | const start = title.range[0] - 1 29 | const end = title.range[1] 30 | return fixer.removeRange([start, end]) 31 | }, 32 | }) 33 | } 34 | }, 35 | } 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /docs/rules/new-color-css-vars.md: -------------------------------------------------------------------------------- 1 | ## Upgrade legacy color CSS variables to Primitives v8 in sx prop 2 | 3 | CSS variables are allowed within the `sx` prop in Primer React components. However, the legacy color CSS variables are 4 | deprecated in favor of the new CSS variables introduced in Primitives v8. This rule will warn you if you are using the 5 | deprecated color CSS variables in the `sx` prop, and autofix it. 6 | 7 | ## Rule Details 8 | 9 | This rule looks inside the `sx` prop for the following properties: 10 | 11 | ```json 12 | [ 13 | "bg", 14 | "backgroundColor", 15 | "color", 16 | "borderColor", 17 | "borderTopColor", 18 | "borderRightColor", 19 | "borderBottomColor", 20 | "borderLeftColor", 21 | "border", 22 | "boxShadow", 23 | "caretColor" 24 | ] 25 | ``` 26 | 27 | The rule references a static JSON file called `css-variable-map.json` that matches the old color CSS variables to a new 28 | one based on the property. We only check `sx` because `stylelint` is used to lint other forms of CSS-in-JS. 29 | 30 | 👎 Examples of **incorrect** code for this rule 31 | 32 | ```jsx 33 | 34 | ``` 35 | 36 | 👍 Examples of **correct** code for this rule: 37 | 38 | ```jsx 39 | 40 | ``` 41 | -------------------------------------------------------------------------------- /src/configs/recommended.js: -------------------------------------------------------------------------------- 1 | const {jsxA11yMapping, githubMapping} = require('./components') 2 | 3 | module.exports = { 4 | parserOptions: { 5 | sourceType: 'module', 6 | ecmaFeatures: { 7 | jsx: true, 8 | }, 9 | }, 10 | plugins: ['primer-react', 'github'], 11 | extends: ['plugin:github/react'], 12 | rules: { 13 | 'primer-react/direct-slot-children': 'error', 14 | 'primer-react/no-system-props': 'warn', 15 | 'primer-react/no-deprecated-experimental-components': 'warn', 16 | 'primer-react/a11y-tooltip-interactive-trigger': 'error', 17 | 'primer-react/new-color-css-vars': 'error', 18 | 'primer-react/a11y-explicit-heading': 'error', 19 | 'primer-react/a11y-no-title-usage': 'error', 20 | 'primer-react/a11y-no-duplicate-form-labels': 'error', 21 | 'primer-react/no-deprecated-props': 'warn', 22 | 'primer-react/a11y-remove-disable-tooltip': 'error', 23 | 'primer-react/a11y-use-accessible-tooltip': 'error', 24 | 'primer-react/no-unnecessary-components': 'error', 25 | 'primer-react/prefer-action-list-item-onselect': 'error', 26 | 'primer-react/no-use-responsive-value': 'error', 27 | }, 28 | settings: { 29 | github: { 30 | components: githubMapping, 31 | }, 32 | 'jsx-a11y': { 33 | components: jsxA11yMapping, 34 | }, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/add-to-inbox.yml: -------------------------------------------------------------------------------- 1 | name: Add to Inbox 📥 2 | on: 3 | issues: 4 | types: [opened, reopened] 5 | 6 | jobs: 7 | add-to-inbox: 8 | if: ${{ github.repository == 'primer/eslint-plugin-primer-react' }} 9 | runs-on: ubuntu-latest 10 | env: 11 | ISSUE_URL: ${{ github.event.issue.html_url }} 12 | PROJECT_ID: 4503 13 | steps: 14 | - id: get-primer-access-token 15 | uses: actions/create-github-app-token@v2 16 | with: 17 | app-id: ${{ vars.PRIMER_ISSUE_TRIAGE_APP_ID }} 18 | private-key: ${{ secrets.PRIMER_ISSUE_TRIAGE_APP_PRIVATE_KEY }} 19 | - name: Add react label to issue 20 | run: | 21 | gh issue edit $ISSUE_URL --add-label react 22 | env: 23 | GH_TOKEN: ${{ steps.get-primer-access-token.outputs.token }} 24 | - id: get-github-access-token 25 | uses: actions/create-github-app-token@v2 26 | with: 27 | app-id: ${{ vars.PRIMER_ISSUE_TRIAGE_APP_ID_FOR_GITHUB }} 28 | private-key: ${{ secrets.PRIMER_ISSUE_TRIAGE_APP_PRIVATE_KEY_FOR_GITHUB }} 29 | owner: github 30 | - name: Add issue to project 31 | run: gh project item-add $PROJECT_ID --url $ISSUE_URL --owner github 32 | env: 33 | GH_TOKEN: ${{ steps.get-github-access-token.outputs.token }} 34 | -------------------------------------------------------------------------------- /docs/rules/enforce-css-module-identifier-casing.md: -------------------------------------------------------------------------------- 1 | # Enforce CSS Module Identifier Casing (enforce-css-module-identifier-casing) 2 | 3 | CSS Modules should expose class names written in PascalCase. 4 | 5 | ## Rule details 6 | 7 | This rule disallows the use of any CSS Module property that does not match the desired casing. 8 | 9 | 👎 Examples of **incorrect** code for this rule: 10 | 11 | ```jsx 12 | /* eslint primer-react/enforce-css-module-identifier-casing: "error" */ 13 | import {Button} from '@primer/react' 14 | import classes from './some.module.css' 15 | 16 | 35 | 36 | ) 37 | ``` 38 | 39 | ## Options 40 | 41 | - `skipImportCheck` (default: `false`) 42 | 43 | By default, the `a11y-tooltip-interactive-trigger` rule will only check for interactive elements in components that 44 | are imported from `@primer/react`. You can disable this behavior by setting `skipImportCheck` to `true`. This is used 45 | for internal linting in the [primer/react](https://github.com/prime/react) repository. 46 | -------------------------------------------------------------------------------- /docs/rules/enforce-button-for-link-with-no-href.md: -------------------------------------------------------------------------------- 1 | # Enforce Button for Link with No href (enforce-button-for-link-with-no-href) 2 | 3 | Primer's `Link` component enables users to navigate between pages. Rendering it without an `href` makes the element behave like a button without the correct semantics, which negatively impacts accessibility. Use the `Button` component to trigger an action, or ensure the `Link` has a valid `href`. 4 | 5 | ## Rule details 6 | 7 | This rule reports any `Link` from `@primer/react` that does not include an `href` prop. 8 | 9 | 👎 Examples of **incorrect** code for this rule: 10 | 11 | ```jsx 12 | /* eslint primer-react/enforce-button-for-link-with-no-href: "error" */ 13 | import {Link} from '@primer/react' 14 | ;Save changes 15 | ``` 16 | 17 | ```jsx 18 | /* eslint primer-react/enforce-button-for-link-with-no-href: "error" */ 19 | import {Link} from '@primer/react' 20 | ;Learn more 21 | ``` 22 | 23 | 👍 Examples of **correct** code for this rule: 24 | 25 | ```jsx 26 | /* eslint primer-react/enforce-button-for-link-with-no-href: "error" */ 27 | import {Link} from '@primer/react' 28 | ;Read the docs 29 | ``` 30 | 31 | ```jsx 32 | /* eslint primer-react/enforce-button-for-link-with-no-href: "error" */ 33 | import {Button, Link} from '@primer/react' 34 | 35 | 36 | View issue 37 | ``` 38 | 39 | ## Options 40 | 41 | This rule has no options. 42 | -------------------------------------------------------------------------------- /docs/rules/prefer-action-list-item-onselect.md: -------------------------------------------------------------------------------- 1 | # Prefer using `onSelect` instead of `onClick` for `ActionList.Item` components (`prefer-action-list-item-onselect`) 2 | 3 | 🔧 The `--fix` option on the [ESLint CLI](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule. 4 | 5 | ## Rule details 6 | 7 | When using the `onClick` attribute on `ActionList.Item` components, this callback only fires when a user clicks on the element with a mouse. If the user navigates to the element with a keyboard and presses the `Enter` key, the callback will not fire. This produces an inaccessible experience for keyboard users. 8 | 9 | Using `onSelect` will lead to a more accessible experience for keyboard users compared to using `onClick`. 10 | 11 | This rule is generally auto-fixable, though you may encounter type checking errors that result from not properly handling keyboard events which are not part of the `onSelect` callback signature. 12 | 13 | 👎 Examples of **incorrect** code for this rule: 14 | 15 | ```jsx 16 | 17 | { 20 | event.preventDefault() 21 | handleClick() 22 | }} 23 | /> 24 | ``` 25 | 26 | 👍 Examples of **correct** code for this rule: 27 | 28 | ```jsx 29 | 30 | { 33 | event.preventDefault() 34 | handleClick() 35 | }} 36 | /> 37 | ``` 38 | -------------------------------------------------------------------------------- /src/rules/enforce-button-for-link-with-no-href.js: -------------------------------------------------------------------------------- 1 | const url = require('../url') 2 | const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute') 3 | const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name') 4 | const {isPrimerComponent} = require('../utils/is-primer-component') 5 | 6 | module.exports = { 7 | meta: { 8 | type: 'error', 9 | docs: { 10 | description: 'Disallow usage of Link component without href', 11 | recommended: true, 12 | url: url(module), 13 | }, 14 | messages: { 15 | noLinkWithoutHref: 'Links without href and other side effects are not accessible. Use a Button instead.', 16 | }, 17 | }, 18 | 19 | create(context) { 20 | const sourceCode = context.sourceCode ?? context.getSourceCode() 21 | return { 22 | JSXElement(node) { 23 | const openingElement = node.openingElement 24 | const elementName = getJSXOpeningElementName(openingElement) 25 | 26 | // Check if this is a Link component from @primer/react 27 | if (elementName === 'Link' && isPrimerComponent(openingElement.name, sourceCode.getScope(node))) { 28 | // Check if the Link has an href attribute 29 | const hrefAttribute = getJSXOpeningElementAttribute(openingElement, 'href') 30 | 31 | if (!hrefAttribute) { 32 | context.report({ 33 | node: openingElement, 34 | messageId: 'noLinkWithoutHref', 35 | }) 36 | } 37 | } 38 | }, 39 | } 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /src/rules/__tests__/a11y-remove-disable-tooltip.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {RuleTester} = require('eslint') 4 | const rule = require('../a11y-remove-disable-tooltip') 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('a11y-remove-disable-tooltip', rule, { 19 | valid: [ 20 | `import {IconButton} from '@primer/react'; 21 | `, 22 | ], 23 | invalid: [ 24 | { 25 | code: ``, 26 | output: ``, 27 | errors: [ 28 | { 29 | messageId: 'removeDisableTooltipProp', 30 | }, 31 | ], 32 | }, 33 | { 34 | code: ``, 35 | output: ``, 36 | errors: [ 37 | { 38 | messageId: 'removeDisableTooltipProp', 39 | }, 40 | ], 41 | }, 42 | { 43 | code: ``, 44 | output: ``, 45 | errors: [ 46 | { 47 | messageId: 'removeDisableTooltipProp', 48 | }, 49 | ], 50 | }, 51 | ], 52 | }) 53 | -------------------------------------------------------------------------------- /src/utils/__tests__/flatten-components.test.js: -------------------------------------------------------------------------------- 1 | const {flattenComponents} = require('../flatten-components') 2 | 3 | const mockComponents = passedObj => { 4 | return { 5 | Button: 'button', 6 | Link: 'a', 7 | Spinner: 'svg', 8 | Radio: 'input', 9 | TextInput: { 10 | Action: 'button', 11 | self: 'input', 12 | }, 13 | ...passedObj, 14 | } 15 | } 16 | 17 | describe('getElementType', function () { 18 | it('flattens passed object 1-level deep', function () { 19 | const result = flattenComponents(mockComponents()) 20 | 21 | const expectedResult = { 22 | Button: 'button', 23 | Link: 'a', 24 | Spinner: 'svg', 25 | Radio: 'input', 26 | TextInput: 'input', 27 | 'TextInput.Action': 'button', 28 | } 29 | 30 | expect(result).toEqual(expectedResult) 31 | }) 32 | 33 | it('ignores objects nested deeper than 1-level', function () { 34 | const result = flattenComponents( 35 | mockComponents({ 36 | Select: { 37 | Items: { 38 | self: 'div', 39 | }, 40 | Option: 'option', 41 | self: 'select', 42 | }, 43 | }), 44 | ) 45 | 46 | const expectedResult = { 47 | Button: 'button', 48 | Link: 'a', 49 | Spinner: 'svg', 50 | Radio: 'input', 51 | TextInput: 'input', 52 | 'TextInput.Action': 'button', 53 | 'Select.Items': { 54 | self: 'div', 55 | }, 56 | Select: 'select', 57 | 'Select.Option': 'option', 58 | } 59 | 60 | expect(result).toEqual(expectedResult) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /docs/rules/a11y-no-duplicate-form-labels.md: -------------------------------------------------------------------------------- 1 | ## Rule Details 2 | 3 | This rule prevents accessibility issues by ensuring form controls have only one label. When a `FormControl` contains both a `FormControl.Label` and a `TextInput` with an `aria-label`, it creates duplicate labels which can confuse screen readers and other assistive technologies. 4 | 5 | 👎 Examples of **incorrect** code for this rule: 6 | 7 | ```jsx 8 | import {FormControl, TextInput} from '@primer/react' 9 | 10 | function ExampleComponent() { 11 | return ( 12 | // TextInput has aria-label when FormControl.Label is present 13 | 14 | Form Input Label 15 | 16 | 17 | ) 18 | } 19 | ``` 20 | 21 | 👍 Examples of **correct** code for this rule: 22 | 23 | ```jsx 24 | import {FormControl, TextInput} from '@primer/react' 25 | 26 | function ExampleComponent() { 27 | return ( 28 | <> 29 | {/* TextInput without aria-label when FormControl.Label is present */} 30 | 31 | Form Input Label 32 | 33 | 34 | 35 | {/* TextInput with aria-label when no FormControl.Label is present */} 36 | 37 | 38 | 39 | 40 | {/* Using visuallyHidden FormControl.Label without aria-label */} 41 | 42 | Form Input Label 43 | 44 | 45 | 46 | ) 47 | } 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/rules/direct-slot-children.md: -------------------------------------------------------------------------------- 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, 4 | the `PageLayout` component renders `PageLayout.Header` in the header area, and `PageLayout.Footer` in the footer area. 5 | These subcomponents must be direct children of the parent component, and cannot be nested inside other components. 6 | 7 | ## Rule details 8 | 9 | This rule enforces that slot components are direct children of their parent component. 10 | 11 | 👎 Examples of **incorrect** code for this rule: 12 | 13 | ```jsx 14 | /* eslint primer-react/direct-slot-children: "error" */ 15 | import {PageLayout} from '@primer/react' 16 | 17 | const MyHeader = () => Header 18 | 19 | const App = () => ( 20 | 21 | 22 | 23 | ) 24 | ``` 25 | 26 | 👍 Examples of **correct** code for this rule: 27 | 28 | ```jsx 29 | /* eslint primer-react/direct-slot-children: "error" */ 30 | import {PageLayout} from '@primer/react' 31 | 32 | const MyHeader = () =>
Header
33 | 34 | const App = () => ( 35 | 36 | 37 | 38 | 39 | 40 | ) 41 | ``` 42 | 43 | ## Options 44 | 45 | - `skipImportCheck` (default: `false`) 46 | 47 | By default, the `direct-slot-children` rule will only check for direct slot children in components that are imported 48 | from `@primer/react`. You can disable this behavior by setting `skipImportCheck` to `true`. This is used for internal 49 | linting in the [primer/react](https://github.com/prime/react) repository. 50 | -------------------------------------------------------------------------------- /src/rules/a11y-remove-disable-tooltip.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const {getJSXOpeningElementAttribute} = require('../utils/get-jsx-opening-element-attribute') 3 | const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name') 4 | 5 | /** 6 | * @type {import('eslint').Rule.RuleModule} 7 | */ 8 | module.exports = { 9 | meta: { 10 | type: 'error', 11 | docs: { 12 | description: 13 | 'Icon buttons should have tooltip by default. Please remove `unsafeDisableTooltip` prop from `IconButton` component to enable the tooltip and help making icon button more accessible.', 14 | recommended: true, 15 | }, 16 | fixable: 'code', 17 | schema: [], 18 | messages: { 19 | removeDisableTooltipProp: 20 | 'Please remove `unsafeDisableTooltip` prop from `IconButton` component to enable the tooltip and help make icon button more accessible.', 21 | }, 22 | }, 23 | create(context) { 24 | return { 25 | JSXOpeningElement(node) { 26 | const openingElName = getJSXOpeningElementName(node) 27 | if (openingElName !== 'IconButton') { 28 | return 29 | } 30 | const unsafeDisableTooltip = getJSXOpeningElementAttribute(node, 'unsafeDisableTooltip') 31 | if (unsafeDisableTooltip !== undefined) { 32 | context.report({ 33 | node, 34 | messageId: 'removeDisableTooltipProp', 35 | fix(fixer) { 36 | const start = unsafeDisableTooltip.range[0] 37 | const end = unsafeDisableTooltip.range[1] 38 | return [ 39 | fixer.removeRange([start - 1, end]), // remove the space before unsafeDisableTooltip as well 40 | ] 41 | }, 42 | }) 43 | } 44 | }, 45 | } 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /src/rules/__tests__/a11y-explicit-heading.test.js: -------------------------------------------------------------------------------- 1 | const rule = require('../a11y-explicit-heading') 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-explicit-heading', rule, { 17 | valid: [ 18 | `import {Heading} from '@primer/react'; 19 | Heading level 1 20 | `, 21 | `import {Heading} from '@primer/react'; 22 | Heading level 2 23 | `, 24 | `import {Heading} from '@primer/react'; 25 | Heading level 3 26 | `, 27 | `import {Heading} from '@primer/react'; 28 | const args = {}; 29 | A heading with spread props 30 | `, 31 | `import {Heading} from '@primer/react'; 32 | const as = 'h3'; 33 | Heading as passed prop 34 | `, 35 | `import {Heading} from '@primer/react'; 36 | const args = {}; 37 | Heading level 2 38 | `, 39 | ` 40 | import {Heading} from '@primer/react'; 41 | 45 | Passed spread props 46 | 47 | `, 48 | ], 49 | invalid: [ 50 | { 51 | code: `import {Heading} from '@primer/react'; 52 | Heading without "as"`, 53 | errors: [{messageId: 'nonExplicitHeadingLevel'}], 54 | }, 55 | { 56 | code: `import {Heading} from '@primer/react'; 57 | Heading component used as "span" 58 | `, 59 | errors: [{messageId: 'invalidAsValue'}], 60 | }, 61 | ], 62 | }) 63 | -------------------------------------------------------------------------------- /src/rules/no-use-responsive-value.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('../url') 4 | 5 | /** 6 | * @type {import('eslint').Rule.RuleModule} 7 | */ 8 | module.exports = { 9 | meta: { 10 | type: 'problem', 11 | docs: { 12 | description: 'Disallow using useResponsiveValue hook', 13 | recommended: true, 14 | url: url(module), 15 | }, 16 | schema: [], 17 | messages: { 18 | noUseResponsiveValue: 'useResponsiveValue is not allowed. Use alternative responsive patterns instead.', 19 | }, 20 | }, 21 | create(context) { 22 | return { 23 | // Check for import declarations 24 | ImportDeclaration(node) { 25 | // Check for @primer/react imports 26 | const isPrimerImport = /@primer\/react/.test(node.source.value) 27 | // Check for local imports that might be useResponsiveValue hook 28 | const isLocalUseResponsiveValueImport = 29 | node.source.value.includes('useResponsiveValue') || node.source.value.includes('/hooks/useResponsiveValue') 30 | 31 | if (!isPrimerImport && !isLocalUseResponsiveValueImport) { 32 | return 33 | } 34 | 35 | for (const specifier of node.specifiers) { 36 | if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'useResponsiveValue') { 37 | context.report({ 38 | node: specifier, 39 | messageId: 'noUseResponsiveValue', 40 | }) 41 | } 42 | // Also check for default imports from useResponsiveValue files 43 | if (specifier.type === 'ImportDefaultSpecifier' && isLocalUseResponsiveValueImport) { 44 | context.report({ 45 | node: specifier, 46 | messageId: 'noUseResponsiveValue', 47 | }) 48 | } 49 | } 50 | }, 51 | } 52 | }, 53 | } 54 | -------------------------------------------------------------------------------- /src/rules/__tests__/no-deprecated-experimental-components.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {RuleTester} = require('eslint') 4 | const rule = require('../no-deprecated-experimental-components') 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-experimental-components', rule, { 19 | valid: [ 20 | { 21 | code: `import {SelectPanel} from '@primer/react'`, 22 | }, 23 | { 24 | code: `import {DataTable} from '@primer/react/experimental'`, 25 | }, 26 | { 27 | code: `import {DataTable, ActionBar} from '@primer/react/experimental'`, 28 | }, 29 | { 30 | code: `import * as RandomComponent from '@primer/react/experimental'`, 31 | }, 32 | ], 33 | invalid: [ 34 | // Single experimental import 35 | { 36 | code: `import {SelectPanel} from '@primer/react/experimental'`, 37 | errors: [ 38 | 'The experimental SelectPanel is deprecated. Please import from the stable entrypoint (@primer/react) if available. Check https://primer.style/product/getting-started/react/migration-guides/ for migration guidance or https://primer.style/product/components/ for alternative components.', 39 | ], 40 | }, 41 | // Multiple experimental import 42 | { 43 | code: `import {SelectPanel, DataTable, ActionBar} from '@primer/react/experimental'`, 44 | errors: [ 45 | 'The experimental SelectPanel is deprecated. Please import from the stable entrypoint (@primer/react) if available. Check https://primer.style/product/getting-started/react/migration-guides/ for migration guidance or https://primer.style/product/components/ for alternative components.', 46 | ], 47 | }, 48 | ], 49 | }) 50 | -------------------------------------------------------------------------------- /docs/rules/a11y-use-accessible-tooltip.md: -------------------------------------------------------------------------------- 1 | # Recommends to use the new accessible tooltip instead of the deprecated one. 2 | 3 | ## Rule details 4 | 5 | This rule suggests switching to the new accessible tooltip from @primer/react instead of the deprecated version. Deprecated props like wrap, noDelay, and align should also be removed. 6 | 7 | Note that the new tooltip is intended for interactive elements only, such as buttons and links, whereas the deprecated tooltip could be applied to any element, though it lacks screen reader accessibility. As a result, the autofix for this rule will only work if the deprecated tooltip is on an interactive element. If it is applied to a non-interactive element, please consult your design team for [an alternative approach](https://primer.style/guides/accessibility/tooltip-alternatives). 8 | 9 | 👎 Examples of **incorrect** code for this rule: 10 | 11 | ```jsx 12 | import {Tooltip} from '@primer/react/deprecated' 13 | 14 | const App = () => ( 15 | 16 | 17 | 18 | ) 19 | ``` 20 | 21 | 👍 Examples of **correct** code for this rule: 22 | 23 | ```jsx 24 | import {Tooltip} from '@primer/react' 25 | 26 | const App = () => ( 27 | 28 | 29 | 30 | ) 31 | ``` 32 | 33 | ## Icon buttons and tooltips 34 | 35 | Even though the below code is perfectly valid, since icon buttons now come with tooltips by default, it is not required to explicitly use the Tooltip component on icon buttons. 36 | 37 | ```jsx 38 | import {IconButton, Tooltip} from '@primer/react' 39 | 40 | function ExampleComponent() { 41 | return ( 42 | 43 | 44 | 45 | ) 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'a11y-explicit-heading': require('./rules/a11y-explicit-heading'), 4 | 'a11y-link-in-text-block': require('./rules/a11y-link-in-text-block'), 5 | 'a11y-no-duplicate-form-labels': require('./rules/a11y-no-duplicate-form-labels'), 6 | 'a11y-no-title-usage': require('./rules/a11y-no-title-usage'), 7 | 'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'), 8 | 'a11y-tooltip-interactive-trigger': require('./rules/a11y-tooltip-interactive-trigger'), 9 | 'a11y-use-accessible-tooltip': require('./rules/a11y-use-accessible-tooltip'), 10 | 'direct-slot-children': require('./rules/direct-slot-children'), 11 | 'enforce-button-for-link-with-no-href': require('./rules/enforce-button-for-link-with-no-href'), 12 | 'enforce-css-module-default-import': require('./rules/enforce-css-module-default-import'), 13 | 'enforce-css-module-identifier-casing': require('./rules/enforce-css-module-identifier-casing'), 14 | 'new-color-css-vars': require('./rules/new-color-css-vars'), 15 | 'no-deprecated-entrypoints': require('./rules/no-deprecated-entrypoints'), 16 | 'no-deprecated-experimental-components': require('./rules/no-deprecated-experimental-components'), 17 | 'no-deprecated-props': require('./rules/no-deprecated-props'), 18 | 'no-system-props': require('./rules/no-system-props'), 19 | 'no-unnecessary-components': require('./rules/no-unnecessary-components'), 20 | 'no-use-responsive-value': require('./rules/no-use-responsive-value'), 21 | 'no-wildcard-imports': require('./rules/no-wildcard-imports'), 22 | 'prefer-action-list-item-onselect': require('./rules/prefer-action-list-item-onselect'), 23 | 'spread-props-first': require('./rules/spread-props-first'), 24 | 'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'), 25 | 'use-styled-react-import': require('./rules/use-styled-react-import'), 26 | }, 27 | configs: { 28 | recommended: require('./configs/recommended'), 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /src/rules/no-deprecated-experimental-components.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('../url') 4 | 5 | const components = [ 6 | { 7 | identifier: 'SelectPanel', 8 | entrypoint: '@primer/react/experimental', 9 | }, 10 | ] 11 | 12 | const entrypoints = new Map() 13 | 14 | for (const component of components) { 15 | if (!entrypoints.has(component.entrypoint)) { 16 | entrypoints.set(component.entrypoint, new Set()) 17 | } 18 | entrypoints.get(component.entrypoint).add(component.identifier) 19 | } 20 | 21 | /** 22 | * @type {import('eslint').Rule.RuleModule} 23 | */ 24 | module.exports = { 25 | meta: { 26 | type: 'problem', 27 | docs: { 28 | description: 'Use a stable component from the `@primer/react` entrypoint, or check the docs for alternatives', 29 | recommended: true, 30 | url: url(module), 31 | }, 32 | fixable: true, 33 | schema: [], 34 | }, 35 | create(context) { 36 | return { 37 | ImportDeclaration(node) { 38 | if (!entrypoints.has(node.source.value)) { 39 | return 40 | } 41 | 42 | const entrypoint = entrypoints.get(node.source.value) 43 | 44 | const experimental = node.specifiers.filter(specifier => { 45 | return entrypoint.has(specifier.imported?.name) 46 | }) 47 | 48 | const components = experimental.map(specifier => specifier.imported?.name) 49 | 50 | if (experimental.length === 0) { 51 | return 52 | } 53 | 54 | if (experimental.length > 0) { 55 | const message = `The experimental ${components.join(', ')} ${ 56 | components.length > 1 ? 'are' : 'is' 57 | } deprecated. Please import from the stable entrypoint (@primer/react) if available. Check https://primer.style/product/getting-started/react/migration-guides/ for migration guidance or https://primer.style/product/components/ for alternative components.` 58 | 59 | context.report({ 60 | node, 61 | message, 62 | }) 63 | } 64 | }, 65 | } 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /src/rules/enforce-css-module-default-import.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | meta: { 3 | type: 'suggestion', 4 | fixable: 'code', 5 | schema: [ 6 | { 7 | properties: { 8 | enforceName: { 9 | type: 'string', 10 | }, 11 | }, 12 | }, 13 | ], 14 | messages: { 15 | badName: 'This default import should match {{enforceName}}', 16 | notDefault: 'Class modules should only import the default object.', 17 | noDefault: 'Class modules should always import default.', 18 | }, 19 | }, 20 | create(context) { 21 | const enforceName = new RegExp(context.options[0]?.enforceName || '.*') 22 | return { 23 | ['ImportDeclaration>Literal[value=/.module.css$/]']: function (node) { 24 | node = node.parent 25 | const defaultSpecifier = node.specifiers.find(spec => spec.type === 'ImportDefaultSpecifier') 26 | const otherSpecifiers = node.specifiers.filter(spec => spec.type !== 'ImportDefaultSpecifier') 27 | const asDefaultSpecifier = otherSpecifiers.find(spec => spec.imported?.name === 'default') 28 | if (!node.specifiers.length) { 29 | context.report({ 30 | node, 31 | messageId: 'noDefault', 32 | }) 33 | } else if (otherSpecifiers.length === 1 && asDefaultSpecifier) { 34 | if (!enforceName.test(asDefaultSpecifier.local.name)) { 35 | context.report({ 36 | node, 37 | messageId: 'badName', 38 | data: {enforceName}, 39 | }) 40 | } 41 | } else if (otherSpecifiers.length) { 42 | context.report({ 43 | node, 44 | messageId: 'notDefault', 45 | }) 46 | } else if (!defaultSpecifier) { 47 | context.report({ 48 | node, 49 | messageId: 'noDefault', 50 | }) 51 | } 52 | if (defaultSpecifier && !enforceName.test(defaultSpecifier.local.name)) { 53 | context.report({ 54 | node, 55 | messageId: 'badName', 56 | data: {enforceName}, 57 | }) 58 | } 59 | }, 60 | } 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /docs/rules/spread-props-first.md: -------------------------------------------------------------------------------- 1 | # Ensure spread props come before other props (spread-props-first) 2 | 3 | Spread props should come before other named props to avoid unintentionally overriding props. When spread props are placed after named props, they can override the named props, which is often unintended and can lead to UI bugs. 4 | 5 | ## Rule details 6 | 7 | This rule enforces that all spread props (`{...rest}`, `{...props}`, etc.) come before any named props in JSX elements. 8 | 9 | 👎 Examples of **incorrect** code for this rule: 10 | 11 | ```jsx 12 | /* eslint primer-react/spread-props-first: "error" */ 13 | 14 | // ❌ Spread after named prop 15 | 16 | 17 | // ❌ Spread in the middle 18 | 19 | 20 | // ❌ Multiple spreads after named props 21 | 22 | ``` 23 | 24 | 👍 Examples of **correct** code for this rule: 25 | 26 | ```jsx 27 | /* eslint primer-react/spread-props-first: "error" */ 28 | 29 | // ✅ Spread before named props 30 | 31 | 32 | // ✅ Multiple spreads before named props 33 | 34 | 35 | // ✅ Only spread props 36 | 37 | 38 | // ✅ Only named props 39 | 40 | ``` 41 | 42 | ## Why this matters 43 | 44 | Placing spread props after named props can cause unexpected behavior: 45 | 46 | ```jsx 47 | // ❌ Bad: className might get overridden by rest 48 | `, 42 | 43 | // Regular HTML link (not Primer Link) 44 | `Click me`, 45 | 46 | // Link from different package 47 | `import {Link} from 'react-router-dom'; 48 | About`, 49 | ], 50 | invalid: [ 51 | { 52 | code: `import {Link} from '@primer/react'; 53 | Invalid Link without href`, 54 | errors: [ 55 | { 56 | messageId: 'noLinkWithoutHref', 57 | }, 58 | ], 59 | }, 60 | { 61 | code: `import {Link} from '@primer/react'; 62 | Invalid Link with class but no href`, 63 | errors: [ 64 | { 65 | messageId: 'noLinkWithoutHref', 66 | }, 67 | ], 68 | }, 69 | { 70 | code: `import {Link} from '@primer/react'; 71 | Invalid inline Link without href`, 72 | errors: [ 73 | { 74 | messageId: 'noLinkWithoutHref', 75 | }, 76 | ], 77 | }, 78 | { 79 | code: `import {Link} from '@primer/react'; 80 | Invalid Link with onClick but no href`, 81 | errors: [ 82 | { 83 | messageId: 'noLinkWithoutHref', 84 | }, 85 | ], 86 | }, 87 | ], 88 | }) 89 | -------------------------------------------------------------------------------- /docs/rules/no-use-responsive-value.md: -------------------------------------------------------------------------------- 1 | # no-use-responsive-value 2 | 3 | Disallow using `useResponsiveValue` hook from `@primer/react` or local imports. 4 | 5 | ## Rule Details 6 | 7 | This rule prevents the use of the `useResponsiveValue` hook from: 8 | 9 | - `@primer/react` package imports (including `/experimental` and `/deprecated` entrypoints) 10 | - Local file imports (relative paths containing `useResponsiveValue`) 11 | 12 | ### Why? 13 | 14 | This hook is not fully SSR compatible as it relies on `useMediaUnsafeSSR` without a `defaultState`. Using `getResponsiveAttributes` is preferred to avoid hydration mismatches. This rule helps enforce consistent usage of SSR-safe responsive patterns across the codebase. 15 | 16 | ## Examples 17 | 18 | ### ❌ Incorrect 19 | 20 | ```js 21 | import {useResponsiveValue} from '@primer/react' 22 | 23 | function Component() { 24 | const value = useResponsiveValue(['sm', 'md', 'lg']) 25 | return
{value}
26 | } 27 | ``` 28 | 29 | ```js 30 | import {Button, useResponsiveValue} from '@primer/react' 31 | ``` 32 | 33 | ```js 34 | import {useResponsiveValue} from '@primer/react/experimental' 35 | ``` 36 | 37 | ```js 38 | import {useResponsiveValue} from '../hooks/useResponsiveValue' 39 | ``` 40 | 41 | ```js 42 | import useResponsiveValue from '../hooks/useResponsiveValue' 43 | ``` 44 | 45 | ```js 46 | import {useResponsiveValue} from './useResponsiveValue' 47 | ``` 48 | 49 | ### ✅ Correct 50 | 51 | ```js 52 | import {Button} from '@primer/react' 53 | 54 | function Component() { 55 | // Use alternative responsive patterns 56 | return 57 | } 58 | ``` 59 | 60 | ```js 61 | import {useResponsiveValue} from 'other-library' 62 | 63 | function Component() { 64 | // Using useResponsiveValue from a different library is allowed 65 | const value = useResponsiveValue(['sm', 'md', 'lg']) 66 | return
{value}
67 | } 68 | ``` 69 | 70 | ```js 71 | import {useCustomHook} from '../hooks/useCustomHook' 72 | 73 | function Component() { 74 | // Importing other hooks from local paths is allowed 75 | const value = useCustomHook(['sm', 'md', 'lg']) 76 | return
{value}
77 | } 78 | ``` 79 | 80 | ```js 81 | function useResponsiveValue() { 82 | // Local function definitions are allowed 83 | return 'custom implementation' 84 | } 85 | 86 | function Component() { 87 | const value = useResponsiveValue() 88 | return
{value}
89 | } 90 | ``` 91 | 92 | ## When Not To Use It 93 | 94 | If your project needs to use `useResponsiveValue` from `@primer/react`, you can disable this rule: 95 | 96 | ```js 97 | /* eslint primer-react/no-use-responsive-value: "off" */ 98 | ``` 99 | 100 | ## Options 101 | 102 | This rule has no options. 103 | -------------------------------------------------------------------------------- /src/rules/__tests__/prefer-action-list-item-onselect.test.js: -------------------------------------------------------------------------------- 1 | const {RuleTester} = require('@typescript-eslint/rule-tester') 2 | const rule = require('../prefer-action-list-item-onselect') 3 | 4 | const ruleTester = new RuleTester({ 5 | languageOptions: { 6 | parser: require(require.resolve('@typescript-eslint/parser', {paths: [require.resolve('eslint-plugin-github')]})), 7 | ecmaVersion: 2018, 8 | sourceType: 'module', 9 | parserOptions: { 10 | ecmaFeatures: { 11 | jsx: true, 12 | }, 13 | }, 14 | }, 15 | }) 16 | 17 | ruleTester.run('prefer-action-list-item-onselect', rule, { 18 | valid: [ 19 | {code: ` console.log(1)} />`}, 20 | {code: ` console.log(1)} onClick={() => console.log(1)} />`}, 21 | {code: ` console.log(1)} />`}, 22 | {code: ``, 51 | errors: [ 52 | {messageId: 'useAccessibleTooltip', line: 1}, 53 | {messageId: 'useTextProp', line: 2}, 54 | {messageId: 'noDelayRemoved', line: 2}, 55 | {messageId: 'wrapRemoved', line: 2}, 56 | {messageId: 'alignRemoved', line: 2}, 57 | ], 58 | output: `import {ActionList, ActionMenu, Button} from '@primer/react/deprecated';\nimport {Tooltip} from '@primer/react';\n`, 59 | }, 60 | ], 61 | }) 62 | -------------------------------------------------------------------------------- /src/rules/spread-props-first.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | meta: { 3 | type: 'problem', 4 | fixable: 'code', 5 | schema: [], 6 | messages: { 7 | spreadPropsFirst: 8 | 'Spread props should come before other props to avoid unintentional overrides. Move {{spreadProp}} before {{namedProp}}.', 9 | }, 10 | }, 11 | create(context) { 12 | return { 13 | JSXOpeningElement(node) { 14 | const attributes = node.attributes 15 | 16 | // Track if we've seen a named prop before a spread 17 | let lastNamedPropIndex = -1 18 | let firstSpreadAfterNamedPropIndex = -1 19 | 20 | for (let i = 0; i < attributes.length; i++) { 21 | const attr = attributes[i] 22 | 23 | if (attr.type === 'JSXAttribute') { 24 | // This is a named prop 25 | lastNamedPropIndex = i 26 | } else if (attr.type === 'JSXSpreadAttribute' && lastNamedPropIndex !== -1) { 27 | // This is a spread prop that comes after a named prop 28 | if (firstSpreadAfterNamedPropIndex === -1) { 29 | firstSpreadAfterNamedPropIndex = i 30 | } 31 | } 32 | } 33 | 34 | // If we found a spread after a named prop, report it 35 | if (firstSpreadAfterNamedPropIndex !== -1) { 36 | const sourceCode = context.sourceCode 37 | const spreadAttr = attributes[firstSpreadAfterNamedPropIndex] 38 | const namedAttr = attributes[lastNamedPropIndex] 39 | 40 | context.report({ 41 | node: spreadAttr, 42 | messageId: 'spreadPropsFirst', 43 | data: { 44 | spreadProp: sourceCode.getText(spreadAttr), 45 | namedProp: namedAttr.name.name, 46 | }, 47 | fix(fixer) { 48 | // Collect all spreads and named props 49 | const spreads = [] 50 | const namedProps = [] 51 | 52 | for (const attr of attributes) { 53 | if (attr.type === 'JSXSpreadAttribute') { 54 | spreads.push(attr) 55 | } else if (attr.type === 'JSXAttribute') { 56 | namedProps.push(attr) 57 | } 58 | } 59 | 60 | // Generate the reordered attributes text 61 | const reorderedAttrs = [...spreads, ...namedProps] 62 | const fixes = [] 63 | 64 | // Replace each attribute with its new position 65 | for (let i = 0; i < attributes.length; i++) { 66 | const newAttr = reorderedAttrs[i] 67 | const oldAttr = attributes[i] 68 | 69 | if (newAttr !== oldAttr) { 70 | fixes.push(fixer.replaceText(oldAttr, sourceCode.getText(newAttr))) 71 | } 72 | } 73 | 74 | return fixes 75 | }, 76 | }) 77 | } 78 | }, 79 | } 80 | }, 81 | } 82 | -------------------------------------------------------------------------------- /src/rules/prefer-action-list-item-onselect.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const messages = { 4 | 'prefer-action-list-item-onselect': `Use the 'onSelect' event handler instead of 'onClick' for ActionList.Item components, so that it is accessible by keyboard and mouse.`, 5 | } 6 | 7 | /** @type {import('@typescript-eslint/utils/ts-eslint').RuleModule} */ 8 | module.exports = { 9 | meta: { 10 | docs: { 11 | description: 12 | 'To do something when an `ActionList.Item` is selected, you should use the `onSelect` event handler instead of `onClick`, because it handles both keyboard and mouse events. Otherwise, it will only be accessible by mouse.', 13 | recommended: true, 14 | }, 15 | messages, 16 | type: 'problem', 17 | schema: [], 18 | fixable: 'code', 19 | }, 20 | defaultOptions: [], 21 | create(context) { 22 | return { 23 | JSXElement(node) { 24 | // Only check components that have the name `ActionList.Item`. We don't check if this comes from Primer 25 | // because the chance of conflict here is very low 26 | const isActionListItem = 27 | node.openingElement.name.type === 'JSXMemberExpression' && 28 | node.openingElement.name.object.type === 'JSXIdentifier' && 29 | node.openingElement.name.object.name === 'ActionList' && 30 | node.openingElement.name.property.name === 'Item' 31 | if (!isActionListItem) return 32 | 33 | const attributes = node.openingElement.attributes 34 | const onClickAttribute = attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.name === 'onClick') 35 | const onSelectAttribute = attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.name === 'onSelect') 36 | 37 | const keyboardHandlers = ['onKeyDown', 'onKeyUp'] 38 | const keyboardAttributes = attributes.filter( 39 | attr => 40 | attr.type === 'JSXAttribute' && 41 | (typeof attr.name.name === 'string' 42 | ? keyboardHandlers.includes(attr.name.name) 43 | : keyboardHandlers.includes(attr.name.name.name)), 44 | ) 45 | 46 | // If the component has `onSelect`, then it's already using the correct event 47 | if (onSelectAttribute) return 48 | // If there is no `onClick` attribute, then we should also be fine 49 | if (!onClickAttribute) return 50 | // If there is an onClick attribute as well as keyboard handlers, we will assume it is handled correctly 51 | if (onClickAttribute && keyboardAttributes.length > 0) return 52 | 53 | context.report({ 54 | node: onClickAttribute, 55 | messageId: 'prefer-action-list-item-onselect', 56 | fix: fixer => { 57 | // Replace `onClick` with `onSelect` 58 | if (onClickAttribute.type === 'JSXAttribute') { 59 | return fixer.replaceText(onClickAttribute.name, 'onSelect') 60 | } 61 | return null 62 | }, 63 | }) 64 | }, 65 | } 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /src/rules/enforce-css-module-identifier-casing.js: -------------------------------------------------------------------------------- 1 | const {availableCasings, casingMatches} = require('../utils/casing-matches') 2 | const {identifierIsCSSModuleBinding} = require('../utils/css-modules') 3 | 4 | module.exports = { 5 | meta: { 6 | type: 'suggestion', 7 | fixable: 'code', 8 | schema: [ 9 | { 10 | properties: { 11 | casing: { 12 | enum: availableCasings, 13 | }, 14 | }, 15 | }, 16 | ], 17 | messages: { 18 | bad: 'Class names should be in a recognisable case, and either an identifier or literal, saw: {{ type }}', 19 | camel: 'Class names should be camelCase in both CSS and JS, saw: {{ name }}', 20 | pascal: 'Class names should be PascalCase in both CSS and JS, saw: {{ name }}', 21 | kebab: 'Class names should be kebab-case in both CSS and JS, saw: {{ name }}', 22 | }, 23 | }, 24 | create(context) { 25 | const casing = context.options[0]?.casing || 'pascal' 26 | return { 27 | ['JSXAttribute[name.name="className"] JSXExpressionContainer>Identifier']: function (node) { 28 | if (!identifierIsCSSModuleBinding(node, context)) return 29 | if (!casingMatches(node.name || '', casing)) { 30 | context.report({ 31 | node, 32 | messageId: casing, 33 | data: {name: node.name}, 34 | }) 35 | } 36 | }, 37 | ['JSXAttribute[name.name="className"] JSXExpressionContainer MemberExpression[object.type="Identifier"]']: 38 | function (node) { 39 | if (!identifierIsCSSModuleBinding(node.object, context)) return 40 | if (!node.computed && node.property?.type === 'Identifier') { 41 | if (!casingMatches(node.property.name || '', casing)) { 42 | context.report({ 43 | node: node.property, 44 | messageId: casing, 45 | data: {name: node.property.name}, 46 | }) 47 | } 48 | } else if (node.property?.type === 'Literal') { 49 | if (!casingMatches(node.property.value || '', casing)) { 50 | context.report({ 51 | node: node.property, 52 | messageId: casing, 53 | data: {name: node.property.value}, 54 | }) 55 | } 56 | } else if (node.computed) { 57 | const ref = context.sourceCode 58 | .getScope(node) 59 | .references.find(reference => reference.identifier.name === node.property.name) 60 | const def = ref?.resolved?.defs?.[0] 61 | if (def?.node?.init?.type === 'Literal') { 62 | if (!casingMatches(def.node.init.value || '', casing)) { 63 | context.report({ 64 | node: node.property, 65 | messageId: casing, 66 | data: {name: def.node.init.value}, 67 | }) 68 | } 69 | } else { 70 | context.report({ 71 | node: node.property, 72 | messageId: 'bad', 73 | data: {type: node.property.type}, 74 | }) 75 | } 76 | } else { 77 | context.report({ 78 | node: node.property, 79 | messageId: 'bad', 80 | data: {type: node.property.type}, 81 | }) 82 | } 83 | }, 84 | } 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /src/rules/__tests__/a11y-no-duplicate-form-labels.test.js: -------------------------------------------------------------------------------- 1 | const rule = require('../a11y-no-duplicate-form-labels') 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-no-duplicate-form-labels', rule, { 17 | valid: [ 18 | // TextInput without aria-label is valid 19 | `import {FormControl, TextInput} from '@primer/react'; 20 | 21 | Form Input Label 22 | 23 | `, 24 | 25 | // TextInput with aria-label but no FormControl.Label is valid 26 | `import {FormControl, TextInput} from '@primer/react'; 27 | 28 | 29 | `, 30 | 31 | // TextInput with aria-label outside FormControl is valid 32 | `import {TextInput} from '@primer/react'; 33 | `, 34 | 35 | // TextInput with visuallyHidden FormControl.Label is valid 36 | `import {FormControl, TextInput} from '@primer/react'; 37 | 38 | Form Input Label 39 | 40 | `, 41 | 42 | // Multiple TextInputs with different approaches 43 | `import {FormControl, TextInput} from '@primer/react'; 44 |
45 | 46 | Visible Label 47 | 48 | 49 | 50 | 51 | 52 |
`, 53 | ], 54 | invalid: [ 55 | { 56 | code: `import {FormControl, TextInput} from '@primer/react'; 57 | 58 | Form Input Label 59 | 60 | `, 61 | errors: [ 62 | { 63 | messageId: 'duplicateLabel', 64 | }, 65 | ], 66 | }, 67 | { 68 | code: `import {FormControl, TextInput} from '@primer/react'; 69 | 70 | Username 71 | 72 | `, 73 | errors: [ 74 | { 75 | messageId: 'duplicateLabel', 76 | }, 77 | ], 78 | }, 79 | { 80 | code: `import {FormControl, TextInput} from '@primer/react'; 81 | 82 | Password 83 | 84 | `, 85 | errors: [ 86 | { 87 | messageId: 'duplicateLabel', 88 | }, 89 | ], 90 | }, 91 | { 92 | code: `import {FormControl, TextInput} from '@primer/react'; 93 |
94 | 95 | Email 96 |
97 | 98 |
99 |
100 |
`, 101 | errors: [ 102 | { 103 | messageId: 'duplicateLabel', 104 | }, 105 | ], 106 | }, 107 | ], 108 | }) 109 | -------------------------------------------------------------------------------- /src/rules/direct-slot-children.js: -------------------------------------------------------------------------------- 1 | const {isPrimerComponent} = require('../utils/is-primer-component') 2 | const {getJSXOpeningElementName} = require('../utils/get-jsx-opening-element-name') 3 | const {last} = require('lodash') 4 | 5 | const slotParentToChildMap = { 6 | PageLayout: ['PageLayout.Header', 'PageLayout.Footer'], 7 | SplitPageLayout: ['SplitPageLayout.Header', 'SplitPageLayout.Footer'], 8 | FormControl: ['FormControl.Label', 'FormControl.Caption', 'FormControl.LeadingVisual', 'FormControl.TrailingVisual'], 9 | 'ActionList.Item': ['ActionList.LeadingVisual', 'ActionList.TrailingVisual', 'ActionList.Description'], 10 | 'ActionList.LinkItem': ['ActionList.LeadingVisual', 'ActionList.TrailingVisual', 'ActionList.Description'], 11 | 'NavList.Item': ['NavList.LeadingVisual', 'NavList.TrailingVisual'], 12 | 'TreeView.Item': ['TreeView.LeadingVisual', 'TreeView.TrailingVisual'], 13 | RadioGroup: ['RadioGroup.Label', 'RadioGroup.Caption', 'RadioGroup.Validation'], 14 | CheckboxGroup: ['CheckboxGroup.Label', 'CheckboxGroup.Caption', 'CheckboxGroup.Validation'], 15 | MarkdownEditor: ['MarkdownEditor.Toolbar', 'MarkdownEditor.Actions', 'MarkdownEditor.Label'], 16 | 'MarkdownEditor.Footer': ['MarkdownEditor.Actions', 'MarkdownEditor.FooterButton'], 17 | } 18 | 19 | const slotChildToParentMap = Object.entries(slotParentToChildMap).reduce((acc, [parent, children]) => { 20 | for (const child of children) { 21 | if (acc[child]) { 22 | acc[child].push(parent) 23 | } else { 24 | acc[child] = [parent] 25 | } 26 | } 27 | return acc 28 | }, {}) 29 | 30 | module.exports = { 31 | meta: { 32 | type: 'problem', 33 | schema: [ 34 | { 35 | properties: { 36 | skipImportCheck: { 37 | type: 'boolean', 38 | }, 39 | }, 40 | }, 41 | ], 42 | messages: { 43 | directSlotChildren: '{{childName}} must be a direct child of {{parentName}}.', 44 | }, 45 | }, 46 | create(context) { 47 | const stack = [] 48 | const sourceCode = context.sourceCode ?? context.getSourceCode() 49 | return { 50 | JSXOpeningElement(jsxNode) { 51 | const name = getJSXOpeningElementName(jsxNode) 52 | 53 | // If `skipImportCheck` is true, this rule will check for direct slot children 54 | // in any components (not just ones that are imported from `@primer/react`). 55 | const skipImportCheck = context.options[0] ? context.options[0].skipImportCheck : false 56 | 57 | // If component is a Primer component and a slot child, 58 | // check if it's a direct child of the slot parent 59 | if ( 60 | (skipImportCheck || isPrimerComponent(jsxNode.name, sourceCode.getScope(jsxNode))) && 61 | slotChildToParentMap[name] 62 | ) { 63 | const expectedParentNames = slotChildToParentMap[name] 64 | const parent = last(stack) 65 | if (!expectedParentNames.includes(parent)) { 66 | context.report({ 67 | node: jsxNode, 68 | messageId: 'directSlotChildren', 69 | data: { 70 | childName: name, 71 | parentName: expectedParentNames.length > 1 ? expectedParentNames.join(' or ') : expectedParentNames[0], 72 | }, 73 | }) 74 | } 75 | } 76 | 77 | // If tag is not self-closing, push it onto the stack 78 | if (!jsxNode.selfClosing) { 79 | stack.push(name) 80 | } 81 | }, 82 | JSXClosingElement() { 83 | // Pop the current element off the stack 84 | stack.pop() 85 | }, 86 | } 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /docs/rules/a11y-link-in-text-block.md: -------------------------------------------------------------------------------- 1 | # EXPERIMENTAL: Require `inline` prop on `` in text block 2 | 3 | This is an experimental rule. If you suspect any false positives reported by this rule, please file an issue so we can make this rule better. 4 | 5 | ## Rule Details 6 | 7 | The `Link` component should have the `inline` prop when it is used within a text block and has no styles (aside from color) to distinguish itself from surrounding plain text. 8 | 9 | Related: [WCAG 1.4.1 Use of Color issues](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html) 10 | 11 | The lint rule will flag any `` without the `inline` property (equal to `true`) detected with string nodes on either side. 12 | 13 | There are certain edge cases that the linter skips to avoid false positives including: 14 | 15 | - `` because there may be distinguishing styles applied. 16 | - `` or `` because these technically may provide sufficient distinguishing styling. 17 | - `` where the only adjacent text is a period, since that can't really be considered a text block. 18 | - `` where the children is a JSX component, rather than a string literal, because then it might be an icon link rather than a text link. 19 | - `` that are nested inside of headings as these have often been breadcrumbs. 20 | 21 | This rule will not catch all instances of link in text block due to the limitations of static analysis, so be sure to also have in-browser checks in place such as the [link-in-text-block Axe rule](https://dequeuniversity.com/rules/axe/4.9/link-in-text-block) for additional coverage. 22 | 23 | 👎 Examples of **incorrect** code for this rule 24 | 25 | ```jsx 26 | import {Link} from '@primer/react' 27 | 28 | function ExampleComponent() { 29 | return ( 30 | 31 | Say hello or not. 32 | 33 | ) 34 | } 35 | ``` 36 | 37 | ```jsx 38 | import {Link} from '@primer/react' 39 | 40 | function ExampleComponent() { 41 | return ( 42 | 43 | Say hello or sign-up. 44 | 45 | ) 46 | } 47 | ``` 48 | 49 | 👍 Examples of **correct** code for this rule: 50 | 51 | ```jsx 52 | function ExampleComponent() { 53 | return ( 54 | 55 | Say hello or not. 56 | 57 | ) 58 | } 59 | ``` 60 | 61 | ```jsx 62 | function ExampleComponent() { 63 | return ( 64 | 65 | Say hello or not. 66 | 67 | ) 68 | } 69 | ``` 70 | 71 | This rule will skip `Link`s containing JSX elements to minimize potential false positives because it is possible the JSX element sufficiently distinguishes the link from surrounding text. 72 | 73 | ```jsx 74 | function ExampleComponent() { 75 | return ( 76 | 77 | 78 | 79 | @monalisa 80 | {' '} 81 | commented on your account. 82 | 83 | ) 84 | } 85 | ``` 86 | 87 | This rule will skip `Link`s nested inside of a `Heading`. 88 | 89 | ```jsx 90 | function ExampleComponent() { 91 | return ( 92 | 93 | Previous location/ Current location 94 | 95 | ) 96 | } 97 | ``` 98 | 99 | This rule will skip `Link`s with a `className`. 100 | 101 | ```jsx 102 | function ExampleComponent() { 103 | return ( 104 | Learn more at GitHub 105 | ) 106 | } 107 | ``` 108 | 109 | ## Options 110 | 111 | - `skipImportCheck` (default: `false`) 112 | 113 | By default, the `a11y-link-in-text-block` rule will only check for `` components imported directly from `@primer/react`. You can disable this behavior by setting `skipImportCheck` to `true`. 114 | -------------------------------------------------------------------------------- /src/rules/__tests__/enforce-css-module-identifier-casing.test.js: -------------------------------------------------------------------------------- 1 | const rule = require('../enforce-css-module-identifier-casing') 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('enforce-css-module-identifier-casing', rule, { 17 | valid: [ 18 | 'import classes from "a.module.css"; function Foo() { return }', 19 | 'import classes from "a.module.css"; function Foo() { return }', 20 | 'import classes from "a.module.css"; function Foo() { return }', 21 | 'import classes from "a.module.css"; function Foo() { return }', 22 | 'import classes from "a.module.css"; function Foo() { return }', 23 | 'import classes from "a.module.css"; let x = "Foo"; function Foo() { return }', 24 | 'import {Foo} from "a.module.css"; function Bar() { return }', 25 | ], 26 | invalid: [ 27 | { 28 | code: 'import classes from "a.module.css"; function Foo() { return }', 29 | errors: [ 30 | { 31 | messageId: 'pascal', 32 | data: {name: 'foo'}, 33 | }, 34 | ], 35 | }, 36 | { 37 | code: 'import {foo} from "a.module.css"; function Bar() { return }', 38 | errors: [ 39 | { 40 | messageId: 'pascal', 41 | data: {name: 'foo'}, 42 | }, 43 | ], 44 | }, 45 | { 46 | code: 'import classes from "a.module.css"; function Foo() { return }', 47 | errors: [ 48 | { 49 | messageId: 'pascal', 50 | data: {name: 'foo'}, 51 | }, 52 | ], 53 | }, 54 | { 55 | code: 'import classes from "a.module.css"; function Foo() { return }', 56 | errors: [ 57 | { 58 | messageId: 'pascal', 59 | data: {name: 'foo'}, 60 | }, 61 | ], 62 | }, 63 | { 64 | code: 'import classes from "a.module.css"; function Foo() { return }', 65 | errors: [ 66 | { 67 | messageId: 'pascal', 68 | data: {name: 'foo'}, 69 | }, 70 | ], 71 | }, 72 | { 73 | code: 'import classes from "a.module.css"; function Foo() { return }', 74 | errors: [ 75 | { 76 | messageId: 'pascal', 77 | data: {name: 'foo'}, 78 | }, 79 | ], 80 | }, 81 | { 82 | code: 'import classes from "a.module.css"; function Foo() { return }', 83 | options: [{casing: 'camel'}], 84 | errors: [ 85 | { 86 | messageId: 'camel', 87 | data: {name: 'Foo'}, 88 | }, 89 | ], 90 | }, 91 | { 92 | code: 'import classes from "a.module.css"; let FooClass = "foo"; function Foo() { return }', 93 | errors: [ 94 | { 95 | messageId: 'pascal', 96 | data: {name: 'foo'}, 97 | }, 98 | ], 99 | }, 100 | { 101 | code: 'import classes from "a.module.css"; function Foo() { return }', 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 | [![npm package](https://img.shields.io/npm/v/eslint-plugin-primer-react.svg)](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';
Header
`, 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';
Header
`, 61 | errors: [ 62 | { 63 | messageId: 'directSlotChildren', 64 | data: {childName: 'PageLayout.Header', parentName: 'PageLayout'}, 65 | }, 66 | ], 67 | }, 68 | { 69 | code: `import {PageLayout} from '@primer/react';
Header
`, 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';
Header
`, 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';
Visual
`, 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 |
44 | Save 45 |
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 |
213 | Save 214 |
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 `