Skip to content

Commit 5f27ec2

Browse files
authored
Add prefer-classlist-toggle rule (#2731)
1 parent 5f275e2 commit 5f27ec2

13 files changed

+1329
-9
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Prefer using `Element#classList.toggle()` to toggle class names
2+
3+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#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) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
Prefer using [`Element#classList.toggle()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/classList) instead of conditionally calling `classList.add()` and `classList.remove()`.
11+
12+
## Examples
13+
14+
```js
15+
//
16+
if (condition) {
17+
element.classList.add('className');
18+
} else {
19+
element.classList.remove('className');
20+
}
21+
22+
//
23+
condition
24+
? element.classList.add('className')
25+
: element.classList.remove('className');
26+
27+
//
28+
element.classList[condition ? 'add' : 'remove']('className')
29+
30+
//
31+
element.classList.toggle('className', condition);
32+
```

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export default [
137137
| [prefer-bigint-literals](docs/rules/prefer-bigint-literals.md) | Prefer `BigInt` literals over the constructor. || 🔧 | 💡 |
138138
| [prefer-blob-reading-methods](docs/rules/prefer-blob-reading-methods.md) | Prefer `Blob#arrayBuffer()` over `FileReader#readAsArrayBuffer(…)` and `Blob#text()` over `FileReader#readAsText(…)`. || | |
139139
| [prefer-class-fields](docs/rules/prefer-class-fields.md) | Prefer class field declarations over `this` assignments in constructors. || 🔧 | 💡 |
140+
| [prefer-classlist-toggle](docs/rules/prefer-classlist-toggle.md) | Prefer using `Element#classList.toggle()` to toggle class names. || 🔧 | 💡 |
140141
| [prefer-code-point](docs/rules/prefer-code-point.md) | Prefer `String#codePointAt(…)` over `String#charCodeAt(…)` and `String.fromCodePoint(…)` over `String.fromCharCode(…)`. || | 💡 |
141142
| [prefer-date-now](docs/rules/prefer-date-now.md) | Prefer `Date.now()` to get the number of milliseconds since the Unix Epoch. || 🔧 | |
142143
| [prefer-default-parameters](docs/rules/prefer-default-parameters.md) | Prefer default parameters over reassignment. || | 💡 |

rules/fix/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ export {default as removeArgument} from './remove-argument.js';
55
export {default as replaceArgument} from './replace-argument.js';
66
export {default as switchNewExpressionToCallExpression} from './switch-new-expression-to-call-expression.js';
77
export {default as switchCallExpressionToNewExpression} from './switch-call-expression-to-new-expression.js';
8-
export {default as removeMemberExpressionProperty} from './remove-member-expression-property.js';
8+
export {
9+
replaceMemberExpressionProperty,
10+
removeMemberExpressionProperty,
11+
} from './replace-member-expression-property.js';
912
export {default as removeMethodCall} from './remove-method-call.js';
1013
export {default as replaceTemplateElement} from './replace-template-element.js';
1114
export {default as replaceReferenceIdentifier} from './replace-reference-identifier.js';

rules/fix/remove-member-expression-property.js

Lines changed: 0 additions & 7 deletions
This file was deleted.

rules/fix/remove-method-call.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {getParenthesizedRange} from '../utils/parentheses.js';
2-
import removeMemberExpressionProperty from './remove-member-expression-property.js';
2+
import {removeMemberExpressionProperty} from './replace-member-expression-property.js';
33

44
export default function * removeMethodCall(fixer, callExpression, sourceCode) {
55
const memberExpression = callExpression.callee;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {getParenthesizedRange} from '../utils/parentheses.js';
2+
3+
export function replaceMemberExpressionProperty(fixer, memberExpression, sourceCode, text) {
4+
const [, start] = getParenthesizedRange(memberExpression.object, sourceCode);
5+
const [, end] = sourceCode.getRange(memberExpression);
6+
return fixer.replaceTextRange([start, end], text);
7+
}
8+
9+
export const removeMemberExpressionProperty = (fixer, memberExpression, sourceCode) => replaceMemberExpressionProperty(fixer, memberExpression, sourceCode, '');

rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export {default as 'prefer-at'} from './prefer-at.js';
8181
export {default as 'prefer-bigint-literals'} from './prefer-bigint-literals.js';
8282
export {default as 'prefer-blob-reading-methods'} from './prefer-blob-reading-methods.js';
8383
export {default as 'prefer-class-fields'} from './prefer-class-fields.js';
84+
export {default as 'prefer-classlist-toggle'} from './prefer-classlist-toggle.js';
8485
export {default as 'prefer-code-point'} from './prefer-code-point.js';
8586
export {default as 'prefer-date-now'} from './prefer-date-now.js';
8687
export {default as 'prefer-default-parameters'} from './prefer-default-parameters.js';

rules/prefer-classlist-toggle.js

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import {
2+
isMethodCall,
3+
isMemberExpression,
4+
isStringLiteral,
5+
isCallExpression,
6+
isExpressionStatement,
7+
} from './ast/index.js';
8+
import {
9+
replaceMemberExpressionProperty,
10+
fixSpaceAroundKeyword,
11+
} from './fix/index.js';
12+
import {
13+
isSameReference,
14+
isParenthesized,
15+
getParenthesizedText,
16+
shouldAddParenthesesToUnaryExpressionArgument,
17+
needsSemicolon,
18+
} from './utils/index.js';
19+
20+
const MESSAGE_ID_ERROR = 'prefer-classlist-toggle/error';
21+
const MESSAGE_ID_SUGGESTION = 'prefer-classlist-toggle/suggestion';
22+
const messages = {
23+
[MESSAGE_ID_ERROR]: 'Prefer using `Element#classList.toggle()` to toggle class names.',
24+
[MESSAGE_ID_SUGGESTION]: 'Replace with `Element#classList.toggle()`.',
25+
};
26+
27+
const isClassList = node => isMemberExpression(node, {
28+
property: 'classList',
29+
computed: false,
30+
});
31+
32+
const getProblem = (valueNode, fix, reportNode) => {
33+
const problem = {
34+
node: reportNode ?? valueNode,
35+
messageId: MESSAGE_ID_ERROR,
36+
};
37+
38+
const shouldUseSuggestion = valueNode.type === 'IfStatement'
39+
? false
40+
: !(isExpressionStatement(valueNode) || isExpressionStatement(valueNode.parent));
41+
42+
if (shouldUseSuggestion) {
43+
problem.suggest = [
44+
{
45+
messageId: MESSAGE_ID_SUGGESTION,
46+
fix,
47+
},
48+
];
49+
} else {
50+
problem.fix = fix;
51+
}
52+
53+
return problem;
54+
};
55+
56+
const getConditionText = (node, sourceCode, isNegative) => {
57+
let text = getParenthesizedText(node, sourceCode);
58+
59+
if (isNegative) {
60+
if (
61+
!isParenthesized(node, sourceCode)
62+
&& shouldAddParenthesesToUnaryExpressionArgument(node, '!')
63+
) {
64+
text = `(${text})`;
65+
}
66+
67+
text = `!${text}`;
68+
return text;
69+
}
70+
71+
if (
72+
!isParenthesized(node, sourceCode)
73+
&& node.type === 'SequenceExpression'
74+
) {
75+
text = `(${text})`;
76+
}
77+
78+
return text;
79+
};
80+
81+
/** @param {import('eslint').Rule.RuleContext} context */
82+
const create = context => {
83+
const {sourceCode} = context;
84+
85+
/*
86+
```js
87+
if (condition) {
88+
element.classList.add('className');
89+
} else {
90+
element.classList.remove('className');
91+
}
92+
```
93+
94+
```js
95+
condition
96+
? element.classList.add('className');
97+
: element.classList.remove('className');
98+
```
99+
*/
100+
context.on(['IfStatement', 'ConditionalExpression'], node => {
101+
const clauses = [node.consequent, node.alternate]
102+
.map(node => {
103+
if (!node) {
104+
return;
105+
}
106+
107+
if (node.type === 'BlockStatement' && node.body.length === 1) {
108+
node = node.body[0];
109+
}
110+
111+
if (node.type === 'ExpressionStatement') {
112+
node = node.expression;
113+
}
114+
115+
if (node.type === 'ChainExpression') {
116+
node = node.expression;
117+
}
118+
119+
return node;
120+
});
121+
122+
// `element.classList.add('className');`
123+
// `element.classList.remove('className');`
124+
if (!clauses.every(node =>
125+
isMethodCall(node, {
126+
methods: ['add', 'remove'],
127+
argumentsLength: 1,
128+
optionalCall: false,
129+
optionalMember: false,
130+
})
131+
&& isClassList(node.callee.object),
132+
)) {
133+
return;
134+
}
135+
136+
const [consequent, alternate] = clauses;
137+
if (
138+
(consequent.callee.property.name === alternate.callee.property.name)
139+
|| !isSameReference(consequent.callee.object, alternate.callee.object)
140+
|| !isSameReference(consequent.arguments[0], alternate.arguments[0])
141+
) {
142+
return;
143+
}
144+
145+
/** @param {import('eslint').Rule.RuleFixer} fixer */
146+
function * fix(fixer) {
147+
const isOptional = consequent.callee.object.optional || alternate.callee.object.optional;
148+
const elementText = getParenthesizedText(consequent.callee.object.object, sourceCode);
149+
const classNameText = getParenthesizedText(consequent.arguments[0], sourceCode);
150+
const isExpression = node.type === 'ConditionalExpression';
151+
const isNegative = consequent.callee.property.name === 'remove';
152+
const conditionText = getConditionText(node.test, sourceCode, isNegative);
153+
154+
let text = `${elementText}${isOptional ? '?' : ''}.classList.toggle(${classNameText}, ${conditionText})`;
155+
156+
if (!isExpression) {
157+
text = `${text};`;
158+
}
159+
160+
if (needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, text)) {
161+
text = `;${text}`;
162+
}
163+
164+
yield fixer.replaceText(node, text);
165+
166+
if (isExpression) {
167+
yield * fixSpaceAroundKeyword(fixer, node, sourceCode);
168+
}
169+
}
170+
171+
return getProblem(node, fix);
172+
});
173+
174+
// `element.classList[condition ? 'add' : 'remove']('className')`
175+
context.on('ConditionalExpression', conditionalExpression => {
176+
const clauses = [conditionalExpression.consequent, conditionalExpression.alternate];
177+
178+
if (!(
179+
clauses.every(node => isStringLiteral(node) && (node.value === 'add' || node.value === 'remove'))
180+
&& clauses[0].value !== clauses[1].value
181+
&& conditionalExpression.parent.type === 'MemberExpression'
182+
&& conditionalExpression.parent.computed
183+
&& !conditionalExpression.parent.optional
184+
&& conditionalExpression.parent.property === conditionalExpression
185+
&& isClassList(conditionalExpression.parent.object)
186+
&& isCallExpression(conditionalExpression.parent.parent, {optional: false, argumentsLength: 1})
187+
&& conditionalExpression.parent.parent.callee === conditionalExpression.parent
188+
)) {
189+
return;
190+
}
191+
192+
const classListMethod = conditionalExpression.parent;
193+
const callExpression = classListMethod.parent;
194+
195+
/** @param {import('eslint').Rule.RuleFixer} fixer */
196+
function * fix(fixer) {
197+
const isNegative = conditionalExpression.consequent.value === 'remove';
198+
const conditionText = getConditionText(conditionalExpression.test, sourceCode, isNegative);
199+
200+
yield fixer.insertTextAfter(callExpression.arguments[0], `, ${conditionText}`);
201+
yield replaceMemberExpressionProperty(fixer, classListMethod, sourceCode, '.toggle');
202+
}
203+
204+
return getProblem(callExpression, fix, conditionalExpression);
205+
});
206+
};
207+
208+
/** @type {import('eslint').Rule.RuleModule} */
209+
const config = {
210+
create,
211+
meta: {
212+
type: 'suggestion',
213+
docs: {
214+
description: 'Prefer using `Element#classList.toggle()` to toggle class names.',
215+
recommended: true,
216+
},
217+
fixable: 'code',
218+
hasSuggestions: true,
219+
messages,
220+
},
221+
};
222+
223+
export default config;

rules/utils/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export {default as shouldAddParenthesesToAwaitExpressionArgument} from './should
5151
export {default as shouldAddParenthesesToCallExpressionCallee} from './should-add-parentheses-to-call-expression-callee.js';
5252
export {default as shouldAddParenthesesToConditionalExpressionChild} from './should-add-parentheses-to-conditional-expression-child.js';
5353
export {default as shouldAddParenthesesToMemberExpressionObject} from './should-add-parentheses-to-member-expression-object.js';
54+
export {default as shouldAddParenthesesToUnaryExpressionArgument} from './should-add-parentheses-to-unary-expression.js';
5455
export {default as singular} from './singular.js';
5556
export {default as toLocation} from './to-location.js';
5657
export {default as getAncestor} from './get-ancestor.js';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
Check if parentheses should be added to a `node` when it's used as `argument` of `UnaryExpression`.
3+
4+
@param {Node} node - The AST node to check.
5+
@param {string} operator - The UnaryExpression operator.
6+
@returns {boolean}
7+
*/
8+
export default function shouldAddParenthesesToUnaryExpressionArgument(node, operator) {
9+
// Only support `!` operator
10+
if (operator !== '!') {
11+
throw new Error('Unexpected operator');
12+
}
13+
14+
return (
15+
node.type === 'UpdateExpression'
16+
|| node.type === 'BinaryExpression'
17+
|| node.type === 'LogicalExpression'
18+
|| node.type === 'ConditionalExpression'
19+
|| node.type === 'AssignmentExpression'
20+
|| node.type === 'ArrowFunctionExpression'
21+
|| node.type === 'YieldExpression'
22+
|| node.type === 'SequenceExpression'
23+
);
24+
}

0 commit comments

Comments
 (0)