diff --git a/docs/rules/README.md b/docs/rules/README.md
index 9b32f097d..2df1e96a0 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -38,6 +38,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
| Rule ID | Description | |
|:--------|:------------|:---|
+| [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce custom event names always use "kebab-case" | |
| [vue/no-arrow-functions-in-watch](./no-arrow-functions-in-watch.md) | disallow using arrow functions to define watcher | |
| [vue/no-async-in-computed-properties](./no-async-in-computed-properties.md) | disallow asynchronous actions in computed properties | |
| [vue/no-deprecated-data-object-declaration](./no-deprecated-data-object-declaration.md) | disallow using deprecated object declaration on data (in Vue.js 3.0.0+) | :wrench: |
@@ -160,6 +161,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
| Rule ID | Description | |
|:--------|:------------|:---|
+| [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce custom event names always use "kebab-case" | |
| [vue/no-arrow-functions-in-watch](./no-arrow-functions-in-watch.md) | disallow using arrow functions to define watcher | |
| [vue/no-async-in-computed-properties](./no-async-in-computed-properties.md) | disallow asynchronous actions in computed properties | |
| [vue/no-custom-modifiers-on-v-model](./no-custom-modifiers-on-v-model.md) | disallow custom modifiers on v-model used on the component | |
diff --git a/docs/rules/custom-event-name-casing.md b/docs/rules/custom-event-name-casing.md
new file mode 100644
index 000000000..cc0153c81
--- /dev/null
+++ b/docs/rules/custom-event-name-casing.md
@@ -0,0 +1,63 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/custom-event-name-casing
+description: enforce custom event names always use "kebab-case"
+---
+# vue/custom-event-name-casing
+> enforce custom event names always use "kebab-case"
+
+- :gear: This rule is included in all of `"plugin:vue/vue3-essential"`, `"plugin:vue/essential"`, `"plugin:vue/vue3-strongly-recommended"`, `"plugin:vue/strongly-recommended"`, `"plugin:vue/vue3-recommended"` and `"plugin:vue/recommended"`.
+
+## :book: Rule Details
+
+This rule enforces using kebab-case custom event names.
+
+> Event names will never be used as variable or property names in JavaScript, so there’s no reason to use camelCase or PascalCase. Additionally, `v-on` event listeners inside DOM templates will be automatically transformed to lowercase (due to HTML’s case-insensitivity), so `v-on:myEvent` would become `v-on:myevent` – making `myEvent` impossible to listen to.
+>
+> For these reasons, we recommend you **always use kebab-case for event names**.
+
+See [Guide - Custom Events] for more details.
+
+
+
+```vue
+
+
+
+
+
+
+
+
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further Reading
+
+- [Guide - Custom Events]
+
+[Guide - Custom Events]: https://vuejs.org/v2/guide/components-custom-events.html
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/custom-event-name-casing.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/custom-event-name-casing.js)
diff --git a/lib/configs/essential.js b/lib/configs/essential.js
index 92a9e7405..9005982b7 100644
--- a/lib/configs/essential.js
+++ b/lib/configs/essential.js
@@ -6,6 +6,7 @@
module.exports = {
extends: require.resolve('./base'),
rules: {
+ 'vue/custom-event-name-casing': 'error',
'vue/no-arrow-functions-in-watch': 'error',
'vue/no-async-in-computed-properties': 'error',
'vue/no-custom-modifiers-on-v-model': 'error',
diff --git a/lib/configs/vue3-essential.js b/lib/configs/vue3-essential.js
index 9ae00e5e1..b2aec42d8 100644
--- a/lib/configs/vue3-essential.js
+++ b/lib/configs/vue3-essential.js
@@ -6,6 +6,7 @@
module.exports = {
extends: require.resolve('./base'),
rules: {
+ 'vue/custom-event-name-casing': 'error',
'vue/no-arrow-functions-in-watch': 'error',
'vue/no-async-in-computed-properties': 'error',
'vue/no-deprecated-data-object-declaration': 'error',
diff --git a/lib/index.js b/lib/index.js
index 6b5ea2011..8f6e7f4d0 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -21,6 +21,7 @@ module.exports = {
'component-definition-name-casing': require('./rules/component-definition-name-casing'),
'component-name-in-template-casing': require('./rules/component-name-in-template-casing'),
'component-tags-order': require('./rules/component-tags-order'),
+ 'custom-event-name-casing': require('./rules/custom-event-name-casing'),
'dot-location': require('./rules/dot-location'),
eqeqeq: require('./rules/eqeqeq'),
'html-closing-bracket-newline': require('./rules/html-closing-bracket-newline'),
diff --git a/lib/rules/custom-event-name-casing.js b/lib/rules/custom-event-name-casing.js
new file mode 100644
index 000000000..cec6f55d7
--- /dev/null
+++ b/lib/rules/custom-event-name-casing.js
@@ -0,0 +1,221 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+/**
+ * @typedef {import('vue-eslint-parser').AST.ESLintLiteral} Literal
+ * @typedef {import('vue-eslint-parser').AST.ESLintCallExpression} CallExpression
+ */
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const { findVariable } = require('eslint-utils')
+const utils = require('../utils')
+const { isKebabCase } = require('../utils/casing')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * Check whether the given event name is valid.
+ * @param {string} name The name to check.
+ * @returns {boolean} `true` if the given event name is valid.
+ */
+function isValidEventName(name) {
+ return isKebabCase(name) || name.startsWith('update:')
+}
+
+/**
+ * Get the name param node from the given CallExpression
+ * @param {CallExpression} node CallExpression
+ * @returns { Literal & { value: string } }
+ */
+function getNameParamNode(node) {
+ const nameLiteralNode = node.arguments[0]
+ if (
+ !nameLiteralNode ||
+ nameLiteralNode.type !== 'Literal' ||
+ typeof nameLiteralNode.value !== 'string'
+ ) {
+ // cannot check
+ return null
+ }
+
+ return nameLiteralNode
+}
+/**
+ * Get the callee member node from the given CallExpression
+ * @param {CallExpression} node CallExpression
+ */
+function getCalleeMemberNode(node) {
+ const callee = node.callee
+
+ if (callee.type === 'MemberExpression') {
+ const name = utils.getStaticPropertyName(callee)
+ if (name) {
+ return { name, member: callee }
+ }
+ }
+ return null
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'enforce custom event names always use "kebab-case"',
+ categories: ['vue3-essential', 'essential'],
+ url: 'https://eslint.vuejs.org/rules/custom-event-name-casing.html'
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ unexpected: "Custom event name '{{name}}' must be kebab-case."
+ }
+ },
+
+ create(context) {
+ const setupContexts = new Map()
+
+ /**
+ * @param { Literal & { value: string } } nameLiteralNode
+ */
+ function verify(nameLiteralNode) {
+ const name = nameLiteralNode.value
+ if (isValidEventName(name)) {
+ return
+ }
+ context.report({
+ node: nameLiteralNode,
+ messageId: 'unexpected',
+ data: {
+ name
+ }
+ })
+ }
+
+ return utils.defineTemplateBodyVisitor(
+ context,
+ {
+ CallExpression(node) {
+ const callee = node.callee
+ const nameLiteralNode = getNameParamNode(node)
+ if (!nameLiteralNode) {
+ // cannot check
+ return
+ }
+ if (callee.type === 'Identifier' && callee.name === '$emit') {
+ verify(nameLiteralNode)
+ }
+ }
+ },
+ utils.compositingVisitors(
+ utils.defineVueVisitor(context, {
+ onSetupFunctionEnter(node, { node: vueNode }) {
+ const contextParam = node.params[1]
+ if (!contextParam) {
+ // no arguments
+ return
+ }
+ if (contextParam.type === 'RestElement') {
+ // cannot check
+ return
+ }
+ if (contextParam.type === 'ArrayPattern') {
+ // cannot check
+ return
+ }
+ const contextReferenceIds = new Set()
+ const emitReferenceIds = new Set()
+ if (contextParam.type === 'ObjectPattern') {
+ const emitProperty = contextParam.properties.find(
+ (p) =>
+ p.type === 'Property' &&
+ utils.getStaticPropertyName(p) === 'emit'
+ )
+ if (!emitProperty) {
+ return
+ }
+ const emitParam = emitProperty.value
+ // `setup(props, {emit})`
+ const variable = findVariable(context.getScope(), emitParam)
+ if (!variable) {
+ return
+ }
+ for (const reference of variable.references) {
+ emitReferenceIds.add(reference.identifier)
+ }
+ } else {
+ // `setup(props, context)`
+ const variable = findVariable(context.getScope(), contextParam)
+ if (!variable) {
+ return
+ }
+ for (const reference of variable.references) {
+ contextReferenceIds.add(reference.identifier)
+ }
+ }
+ setupContexts.set(vueNode, {
+ contextReferenceIds,
+ emitReferenceIds
+ })
+ },
+ CallExpression(node, { node: vueNode }) {
+ const nameLiteralNode = getNameParamNode(node)
+ if (!nameLiteralNode) {
+ // cannot check
+ return
+ }
+
+ // verify setup context
+ const setupContext = setupContexts.get(vueNode)
+ if (setupContext) {
+ const { contextReferenceIds, emitReferenceIds } = setupContext
+ if (emitReferenceIds.has(node.callee)) {
+ // verify setup(props,{emit}) {emit()}
+ verify(nameLiteralNode)
+ } else {
+ const emit = getCalleeMemberNode(node)
+ if (
+ emit &&
+ emit.name === 'emit' &&
+ contextReferenceIds.has(emit.member.object)
+ ) {
+ // verify setup(props,context) {context.emit()}
+ verify(nameLiteralNode)
+ }
+ }
+ }
+ },
+ onVueObjectExit(node) {
+ setupContexts.delete(node)
+ }
+ }),
+ {
+ CallExpression(node) {
+ const nameLiteralNode = getNameParamNode(node)
+ if (!nameLiteralNode) {
+ // cannot check
+ return
+ }
+ const emit = getCalleeMemberNode(node)
+ // verify $emit
+ if (emit && emit.name === '$emit') {
+ // verify this.$emit()
+ verify(nameLiteralNode)
+ }
+ }
+ }
+ )
+ )
+ }
+}
diff --git a/tests/lib/rules/custom-event-name-casing.js b/tests/lib/rules/custom-event-name-casing.js
new file mode 100644
index 000000000..b767223e2
--- /dev/null
+++ b/tests/lib/rules/custom-event-name-casing.js
@@ -0,0 +1,221 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/custom-event-name-casing')
+
+const tester = new RuleTester({
+ parser: require.resolve('vue-eslint-parser'),
+ parserOptions: {
+ ecmaVersion: 2019,
+ sourceType: 'module'
+ }
+})
+
+tester.run('custom-event-name-casing', 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: `
+
+ `
+ }
+ ],
+ invalid: [
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: "Custom event name 'fooBar' must be kebab-case.",
+ line: 4,
+ column: 25,
+ endLine: 4,
+ endColumn: 33
+ },
+ {
+ message: "Custom event name 'barBaz' must be kebab-case.",
+ line: 11,
+ column: 28,
+ endLine: 11,
+ endColumn: 36
+ },
+ {
+ message: "Custom event name 'bazQux' must be kebab-case.",
+ line: 17,
+ column: 24,
+ endLine: 17,
+ endColumn: 32
+ }
+ ]
+ }
+ ]
+})