Skip to content

Commit

Permalink
Add prefer-action-list-item-onselect (#211)
Browse files Browse the repository at this point in the history
* Add rule

* Add tests

* Add rule to recommended config

* Add autofixer

* Add docs

* Add changeset

* Fix typo

* Use replaceText instead of replaceTextRange

---------

Co-authored-by: Ian Sanders <iansan5653@github.com>
  • Loading branch information
camchenry and iansan5653 authored Aug 28, 2024
1 parent ab8269f commit 3f92cd4
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/sixty-spiders-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-primer-react': major
---

[Breaking] Adds `prefer-action-list-item-onselect` lint rule and enables it by default. This may raise new auto-fixable lint errors or type-checking errors in existing codebases.
37 changes: 37 additions & 0 deletions docs/rules/prefer-action-list-item-onselect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Prefer using `onSelect` instead of `onClick` for `ActionList.Item` components (`prefer-action-list-item-onselect`)

🔧 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.

## Rule details

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.

Using `onSelect` will lead to a more accessible experience for keyboard users compared to using `onClick`.

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.

👎 Examples of **incorrect** code for this rule:

```jsx
<ActionList.Item onClick={handleClick} />
<ActionList.Item
aria-label="Edit item 1"
onClick={(event) => {
event.preventDefault()
handleClick()
}}
/>
```

👍 Examples of **correct** code for this rule:

```jsx
<ActionList.Item onSelect={handleClick} />
<ActionList.Item
aria-label="Edit item 1"
onSelect={(event) => {
event.preventDefault()
handleClick()
}}
/>
```
1 change: 1 addition & 0 deletions src/configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module.exports = {
'primer-react/a11y-remove-disable-tooltip': 'error',
'primer-react/a11y-use-next-tooltip': 'error',
'primer-react/no-unnecessary-components': 'error',
'primer-react/prefer-action-list-item-onselect': 'error',
},
settings: {
github: {
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = {
'a11y-use-next-tooltip': require('./rules/a11y-use-next-tooltip'),
'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'),
'primer-react/no-unnecessary-components': require('./rules/no-unnecessary-components'),
'primer-react/prefer-action-list-item-onselect': require('./rules/prefer-action-list-item-onselect'),
},
configs: {
recommended: require('./configs/recommended'),
Expand Down
67 changes: 67 additions & 0 deletions src/rules/__tests__/prefer-action-list-item-onselect.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const {RuleTester} = require('@typescript-eslint/rule-tester')
const rule = require('../prefer-action-list-item-onselect')

const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
})

ruleTester.run('prefer-action-list-item-onselect', rule, {
valid: [
{code: `<ActionList.Item onSelect={() => console.log(1)} />`},
{code: `<ActionList.Item onSelect={() => console.log(1)} onClick={() => console.log(1)} />`},
{code: `<Other onClick={() => console.log(1)} />`},
{code: `<Button onClick={onSelect} />`},
{code: `<Button onSelect={onClick} />`},
{code: `<ActionList.Item onClick={() => console.log(1)} onKeyDown={() => console.log(1)} />`},
{code: `<ActionList.Item onClick={() => console.log(1)} onKeyUp={() => console.log(1)} />`},
// For now, we are not handling spread attributes as this is much less common
{code: `<ActionList.Item {...onClick} />`},
],
invalid: [
{
code: `<ActionList.Item onClick={() => console.log(1)} />`,
errors: [{messageId: 'prefer-action-list-item-onselect'}],
output: `<ActionList.Item onSelect={() => console.log(1)} />`,
},
{
code: `<ActionList.Item aria-label="Edit item 1" onClick={() => console.log(1)} />`,
errors: [{messageId: 'prefer-action-list-item-onselect'}],
output: `<ActionList.Item aria-label="Edit item 1" onSelect={() => console.log(1)} />`,
},
{
code: `<ActionList.Item aria-label="Edit item 1" onClick={onClick} />`,
errors: [{messageId: 'prefer-action-list-item-onselect'}],
output: `<ActionList.Item aria-label="Edit item 1" onSelect={onClick} />`,
},
{
code: `<ActionList.Item
aria-label="Edit item 1"
onClick={(event) => {
event.preventDefault()
handleClick()
}}
/>`,
errors: [{messageId: 'prefer-action-list-item-onselect'}],
output: `<ActionList.Item
aria-label="Edit item 1"
onSelect={(event) => {
event.preventDefault()
handleClick()
}}
/>`,
},
// This is invalid, but we can fix it anyway
{
code: `<ActionList.Item aria-label="Edit item 1" onClick />`,
errors: [{messageId: 'prefer-action-list-item-onselect'}],
output: `<ActionList.Item aria-label="Edit item 1" onSelect />`,
},
],
})
67 changes: 67 additions & 0 deletions src/rules/prefer-action-list-item-onselect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @ts-check

const messages = {
'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.`,
}

/** @type {import('@typescript-eslint/utils/ts-eslint').RuleModule<keyof typeof messages>} */
module.exports = {
meta: {
docs: {
description:
'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.',
recommended: true,
},
messages,
type: 'problem',
schema: [],
fixable: 'code',
},
defaultOptions: [],
create(context) {
return {
JSXElement(node) {
// Only check components that have the name `ActionList.Item`. We don't check if this comes from Primer
// because the chance of conflict here is very low
const isActionListItem =
node.openingElement.name.type === 'JSXMemberExpression' &&
node.openingElement.name.object.type === 'JSXIdentifier' &&
node.openingElement.name.object.name === 'ActionList' &&
node.openingElement.name.property.name === 'Item'
if (!isActionListItem) return

const attributes = node.openingElement.attributes
const onClickAttribute = attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.name === 'onClick')
const onSelectAttribute = attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.name === 'onSelect')

const keyboardHandlers = ['onKeyDown', 'onKeyUp']
const keyboardAttributes = attributes.filter(
attr =>
attr.type === 'JSXAttribute' &&
(typeof attr.name.name === 'string'
? keyboardHandlers.includes(attr.name.name)
: keyboardHandlers.includes(attr.name.name.name)),
)

// If the component has `onSelect`, then it's already using the correct event
if (onSelectAttribute) return
// If there is no `onClick` attribute, then we should also be fine
if (!onClickAttribute) return
// If there is an onClick attribute as well as keyboard handlers, we will assume it is handled correctly
if (onClickAttribute && keyboardAttributes.length > 0) return

context.report({
node: onClickAttribute,
messageId: 'prefer-action-list-item-onselect',
fix: fixer => {
// Replace `onClick` with `onSelect`
if (onClickAttribute.type === 'JSXAttribute') {
return fixer.replaceText(onClickAttribute.name, 'onSelect')
}
return null
},
})
},
}
},
}

0 comments on commit 3f92cd4

Please sign in to comment.