diff --git a/docs/rules/README.md b/docs/rules/README.md
index 5794cc3ed..e3c1d19e7 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -350,6 +350,7 @@ For example:
| [vue/no-useless-v-bind](./no-useless-v-bind.md) | disallow unnecessary `v-bind` directives | :wrench: |
| [vue/no-v-text](./no-v-text.md) | disallow use of v-text | |
| [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
+| [vue/prefer-separate-static-class](./prefer-separate-static-class.md) | require static class names in template to be in a separate `class` attribute | :wrench: |
| [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported | |
| [vue/require-emit-validator](./require-emit-validator.md) | require type definitions in emits | :bulb: |
| [vue/require-expose](./require-expose.md) | require declare public properties using `expose` | :bulb: |
diff --git a/docs/rules/prefer-separate-static-class.md b/docs/rules/prefer-separate-static-class.md
new file mode 100644
index 000000000..155c4fb5c
--- /dev/null
+++ b/docs/rules/prefer-separate-static-class.md
@@ -0,0 +1,43 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/prefer-separate-static-class
+description: require static class names in template to be in a separate `class` attribute
+---
+# vue/prefer-separate-static-class
+
+> require static class names in template to be in a separate `class` attribute
+
+- :exclamation: ***This rule has not been released yet.***
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+## :book: Rule Details
+
+This rule reports static class names in dynamic class attributes.
+
+
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/prefer-separate-static-class.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/prefer-separate-static-class.js)
diff --git a/lib/index.js b/lib/index.js
index c9969917c..50a190bae 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -153,6 +153,7 @@ module.exports = {
'operator-linebreak': require('./rules/operator-linebreak'),
'order-in-components': require('./rules/order-in-components'),
'padding-line-between-blocks': require('./rules/padding-line-between-blocks'),
+ 'prefer-separate-static-class': require('./rules/prefer-separate-static-class'),
'prefer-template': require('./rules/prefer-template'),
'prop-name-casing': require('./rules/prop-name-casing'),
'require-component-is': require('./rules/require-component-is'),
diff --git a/lib/rules/prefer-separate-static-class.js b/lib/rules/prefer-separate-static-class.js
new file mode 100644
index 000000000..504d95d60
--- /dev/null
+++ b/lib/rules/prefer-separate-static-class.js
@@ -0,0 +1,231 @@
+/**
+ * @author Flo Edelmann
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const { defineTemplateBodyVisitor, getStringLiteralValue } = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * @param {ASTNode} node
+ * @returns {node is Literal | TemplateLiteral}
+ */
+function isStringLiteral(node) {
+ return (
+ (node.type === 'Literal' && typeof node.value === 'string') ||
+ (node.type === 'TemplateLiteral' && node.expressions.length === 0)
+ )
+}
+
+/**
+ * @param {Expression | VForExpression | VOnExpression | VSlotScopeExpression | VFilterSequenceExpression} expressionNode
+ * @returns {(Literal | TemplateLiteral | Identifier)[]}
+ */
+function findStaticClasses(expressionNode) {
+ if (isStringLiteral(expressionNode)) {
+ return [expressionNode]
+ }
+
+ if (expressionNode.type === 'ArrayExpression') {
+ return expressionNode.elements.flatMap((element) => {
+ if (element === null || element.type === 'SpreadElement') {
+ return []
+ }
+ return findStaticClasses(element)
+ })
+ }
+
+ if (expressionNode.type === 'ObjectExpression') {
+ return expressionNode.properties.flatMap((property) => {
+ if (
+ property.type === 'Property' &&
+ property.value.type === 'Literal' &&
+ property.value.value === true &&
+ (isStringLiteral(property.key) ||
+ (property.key.type === 'Identifier' && !property.computed))
+ ) {
+ return [property.key]
+ }
+ return []
+ })
+ }
+
+ return []
+}
+
+/**
+ * @param {VAttribute | VDirective} attributeNode
+ * @returns {attributeNode is VAttribute & { value: VLiteral }}
+ */
+function isStaticClassAttribute(attributeNode) {
+ return (
+ !attributeNode.directive &&
+ attributeNode.key.name === 'class' &&
+ attributeNode.value !== null
+ )
+}
+
+/**
+ * Removes the node together with the comma before or after the node.
+ * @param {RuleFixer} fixer
+ * @param {ParserServices.TokenStore} tokenStore
+ * @param {ASTNode} node
+ */
+function* removeNodeWithComma(fixer, tokenStore, node) {
+ const prevToken = tokenStore.getTokenBefore(node)
+ if (prevToken.type === 'Punctuator' && prevToken.value === ',') {
+ yield fixer.removeRange([prevToken.range[0], node.range[1]])
+ return
+ }
+
+ const [nextToken, nextNextToken] = tokenStore.getTokensAfter(node, {
+ count: 2
+ })
+ if (
+ nextToken.type === 'Punctuator' &&
+ nextToken.value === ',' &&
+ (nextNextToken.type !== 'Punctuator' ||
+ (nextNextToken.value !== ']' && nextNextToken.value !== '}'))
+ ) {
+ yield fixer.removeRange([node.range[0], nextNextToken.range[0]])
+ return
+ }
+
+ yield fixer.remove(node)
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description:
+ 'require static class names in template to be in a separate `class` attribute',
+ categories: undefined,
+ url: 'https://eslint.vuejs.org/rules/prefer-separate-static-class.html'
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ preferSeparateStaticClass:
+ 'Static class "{{className}}" should be in a static `class` attribute.'
+ }
+ },
+ /** @param {RuleContext} context */
+ create(context) {
+ return defineTemplateBodyVisitor(context, {
+ /** @param {VDirectiveKey} directiveKeyNode */
+ "VAttribute[directive=true] > VDirectiveKey[name.name='bind'][argument.name='class']"(
+ directiveKeyNode
+ ) {
+ const attributeNode = directiveKeyNode.parent
+ if (!attributeNode.value || !attributeNode.value.expression) {
+ return
+ }
+
+ const expressionNode = attributeNode.value.expression
+ const staticClassNameNodes = findStaticClasses(expressionNode)
+
+ for (const staticClassNameNode of staticClassNameNodes) {
+ const className =
+ staticClassNameNode.type === 'Identifier'
+ ? staticClassNameNode.name
+ : getStringLiteralValue(staticClassNameNode, true)
+
+ if (className === null) {
+ continue
+ }
+
+ context.report({
+ node: staticClassNameNode,
+ messageId: 'preferSeparateStaticClass',
+ data: { className },
+ *fix(fixer) {
+ let dynamicClassDirectiveRemoved = false
+
+ yield* removeFromClassDirective()
+ yield* addToClassAttribute()
+
+ /**
+ * Remove class from dynamic `:class` directive.
+ */
+ function* removeFromClassDirective() {
+ if (isStringLiteral(expressionNode)) {
+ yield fixer.remove(attributeNode)
+ dynamicClassDirectiveRemoved = true
+ return
+ }
+
+ const listElement =
+ staticClassNameNode.parent.type === 'Property'
+ ? staticClassNameNode.parent
+ : staticClassNameNode
+
+ const listNode = listElement.parent
+ if (
+ listNode.type === 'ArrayExpression' ||
+ listNode.type === 'ObjectExpression'
+ ) {
+ const elements =
+ listNode.type === 'ObjectExpression'
+ ? listNode.properties
+ : listNode.elements
+
+ if (elements.length === 1 && listNode === expressionNode) {
+ yield fixer.remove(attributeNode)
+ dynamicClassDirectiveRemoved = true
+ return
+ }
+
+ const tokenStore =
+ context.parserServices.getTemplateBodyTokenStore()
+
+ if (elements.length === 1) {
+ yield* removeNodeWithComma(fixer, tokenStore, listNode)
+ return
+ }
+
+ yield* removeNodeWithComma(fixer, tokenStore, listElement)
+ }
+ }
+
+ /**
+ * Add class to static `class` attribute.
+ */
+ function* addToClassAttribute() {
+ const existingStaticClassAttribute =
+ attributeNode.parent.attributes.find(isStaticClassAttribute)
+ if (existingStaticClassAttribute) {
+ const literalNode = existingStaticClassAttribute.value
+ yield fixer.replaceText(
+ literalNode,
+ `"${literalNode.value} ${className}"`
+ )
+ return
+ }
+
+ // new static `class` attribute
+ const separator = dynamicClassDirectiveRemoved ? '' : ' '
+ yield fixer.insertTextBefore(
+ attributeNode,
+ `class="${className}"${separator}`
+ )
+ }
+ }
+ })
+ }
+ }
+ })
+ }
+}
diff --git a/tests/lib/rules/prefer-separate-static-class.js b/tests/lib/rules/prefer-separate-static-class.js
new file mode 100644
index 000000000..498fc90dd
--- /dev/null
+++ b/tests/lib/rules/prefer-separate-static-class.js
@@ -0,0 +1,333 @@
+/**
+ * @author Flo Edelmann
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/prefer-separate-static-class')
+
+const tester = new RuleTester({
+ parser: require.resolve('vue-eslint-parser'),
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: 'module'
+ }
+})
+
+tester.run('prefer-separate-static-class', rule, {
+ valid: [
+ {
+ filename: 'test.vue',
+ code: ``
+ },
+ {
+ filename: 'test.vue',
+ code: ``
+ },
+ {
+ filename: 'test.vue',
+ code: ''
+ },
+ {
+ filename: 'test.vue',
+ code: ``
+ },
+ {
+ filename: 'test.vue',
+ code: ``
+ },
+ {
+ filename: 'test.vue',
+ code: ``
+ },
+ {
+ filename: 'test.vue',
+ code: ``
+ },
+ {
+ filename: 'test.vue',
+ code: ``
+ },
+ {
+ filename: 'test.vue',
+ code: ``
+ }
+ ],
+ invalid: [
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 30,
+ endColumn: 44
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 24,
+ endColumn: 38
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: '',
+ output: '',
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 24,
+ endColumn: 38
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 24,
+ endColumn: 38
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 25,
+ endColumn: 39
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 25,
+ endColumn: 39
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "foo" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 25,
+ endColumn: 28
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 26,
+ endColumn: 40
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 25,
+ endColumn: 39
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 58,
+ endColumn: 72
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 25,
+ endColumn: 39
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 48,
+ endColumn: 62
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: ``,
+ output: ``,
+ errors: [
+ {
+ message:
+ 'Static class "staticClass" should be in a static `class` attribute.',
+ line: 1,
+ endLine: 1,
+ column: 40,
+ endColumn: 51
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+ `,
+ output: `
+
+
+
+ `,
+ errors: [
+ {
+ message:
+ 'Static class "static-class" should be in a static `class` attribute.',
+ line: 7,
+ endLine: 7,
+ column: 40,
+ endColumn: 54
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+ `,
+ output: `
+
+
+
+ `,
+ errors: [
+ {
+ message:
+ 'Static class "static-class-a" should be in a static `class` attribute.',
+ line: 7,
+ endLine: 7,
+ column: 15,
+ endColumn: 31
+ },
+ {
+ message:
+ 'Static class "static-class-b" should be in a static `class` attribute.',
+ line: 8,
+ endLine: 8,
+ column: 16,
+ endColumn: 32
+ }
+ ]
+ }
+ ]
+})