]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat: compound expression for `v-on` (#60)
authorRizumu Ayaka <rizumu@ayaka.moe>
Mon, 8 Jan 2024 06:07:49 +0000 (14:07 +0800)
committerGitHub <noreply@github.com>
Mon, 8 Jan 2024 06:07:49 +0000 (14:07 +0800)
packages/compiler-vapor/__tests__/transforms/__snapshots__/vOn.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/vOn.spec.ts
packages/compiler-vapor/src/generate.ts
packages/reactivity/src/index.ts
packages/runtime-vapor/src/index.ts
packages/runtime-vapor/src/on.ts
playground/src/event-modifier.vue

index efea7f51d08c22f9940b1d5cdc54d1155aa7e232..7aa9e632ac6634e4d0f8b6a74968f8433e5f51cd 100644 (file)
@@ -12,6 +12,18 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`v-on > complex member expression w/ prefixIdentifiers: true 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args)))
+  return n0
+}"
+`;
+
 exports[`v-on > dynamic arg 1`] = `
 "import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor';
 
@@ -26,6 +38,34 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`v-on > dynamic arg with complex exp prefixing 1`] = `
+"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _renderEffect(() => {
+    _on(n1, _ctx.event(_ctx.foo), (...args) => (_ctx.handler && _ctx.handler(...args)))
+  })
+  return n0
+}"
+`;
+
+exports[`v-on > dynamic arg with prefixing 1`] = `
+"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _renderEffect(() => {
+    _on(n1, _ctx.event, (...args) => (_ctx.handler && _ctx.handler(...args)))
+  })
+  return n0
+}"
+`;
+
 exports[`v-on > event modifier 1`] = `
 "import { template as _template, children as _children, on as _on, withModifiers as _withModifiers, withKeys as _withKeys } from 'vue/vapor';
 
@@ -59,6 +99,133 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`v-on > function expression w/ prefixIdentifiers: true 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", e => _ctx.foo(e))
+  return n0
+}"
+`;
+
+exports[`v-on > inline statement w/ prefixIdentifiers: true 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", $event => (_ctx.foo($event)))
+  return n0
+}"
+`;
+
+exports[`v-on > multiple inline statements w/ prefixIdentifiers: true 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", $event => {_ctx.foo($event);_ctx.bar()})
+  return n0
+}"
+`;
+
+exports[`v-on > should NOT add a prefix to $event if the expression is a function expression 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", $event => {_ctx.i++;_ctx.foo($event)})
+  return n0
+}"
+`;
+
+exports[`v-on > should NOT wrap as function if expression is already function expression (with Typescript) 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", (e: any): any => _ctx.foo(e))
+  return n0
+}"
+`;
+
+exports[`v-on > should NOT wrap as function if expression is already function expression (with newlines) 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", 
+      $event => {
+        _ctx.foo($event)
+      }
+    )
+  return n0
+}"
+`;
+
+exports[`v-on > should NOT wrap as function if expression is already function expression 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", $event => _ctx.foo($event))
+  return n0
+}"
+`;
+
+exports[`v-on > should NOT wrap as function if expression is complex member expression 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args)))
+  return n0
+}"
+`;
+
+exports[`v-on > should handle multi-line statement 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", $event => {
+_ctx.foo();
+_ctx.bar()
+})
+  return n0
+}"
+`;
+
+exports[`v-on > should handle multiple inline statement 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", $event => {_ctx.foo();_ctx.bar()})
+  return n0
+}"
+`;
+
 exports[`v-on > should not wrap keys guard if no key modifier is present 1`] = `
 "import { template as _template, children as _children, on as _on, withModifiers as _withModifiers } from 'vue/vapor';
 
@@ -148,6 +315,32 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`v-on > should wrap as function if expression is inline statement 1`] = `
+"import { template as _template, children as _children, on as _on } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _on(n1, "click", $event => (_ctx.i++))
+  return n0
+}"
+`;
+
+exports[`v-on > should wrap both for dynamic key event w/ left/right modifiers 1`] = `
+"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on, withKeys as _withKeys, withModifiers as _withModifiers } from 'vue/vapor';
+
+export function render(_ctx) {
+  const t0 = _template("<div></div>")
+  const n0 = t0()
+  const { 0: [n1],} = _children(n0)
+  _renderEffect(() => {
+    _on(n1, _ctx.e, _withKeys(_withModifiers((...args) => (_ctx.test && _ctx.test(...args)), ["left"]), ["left"]))
+  })
+  return n0
+}"
+`;
+
 exports[`v-on > should wrap keys guard for keyboard events or dynamic events 1`] = `
 "import { template as _template, children as _children, on as _on, withKeys as _withKeys, withModifiers as _withModifiers } from 'vue/vapor';
 
index 08a73b43016ce7d68ce4818eb69cc29070f3abfc..0b64270696c5f1664b3c08fbc88a87bdb182095a 100644 (file)
@@ -124,30 +124,254 @@ describe('v-on', () => {
     expect(code).matchSnapshot()
   })
 
-  test.todo('dynamic arg with prefixing')
-  test.todo('dynamic arg with complex exp prefixing')
-  test.todo('should wrap as function if expression is inline statement')
-  test.todo('should handle multiple inline statement')
-  test.todo('should handle multi-line statement')
-  test.todo('inline statement w/ prefixIdentifiers: true')
-  test.todo('multiple inline statements w/ prefixIdentifiers: true')
-  test.todo(
-    'should NOT wrap as function if expression is already function expression',
-  )
-  test.todo(
+  test('dynamic arg with prefixing', () => {
+    const { code } = compileWithVOn(`<div v-on:[event]="handler"/>`, {
+      prefixIdentifiers: true,
+    })
+
+    expect(code).matchSnapshot()
+  })
+
+  test('dynamic arg with complex exp prefixing', () => {
+    const { ir, code } = compileWithVOn(`<div v-on:[event(foo)]="handler"/>`, {
+      prefixIdentifiers: true,
+    })
+
+    expect(ir.vaporHelpers).contains('on')
+    expect(ir.vaporHelpers).contains('renderEffect')
+    expect(ir.helpers.size).toBe(0)
+    expect(ir.operation).toEqual([])
+
+    expect(ir.effect[0].operations[0]).toMatchObject({
+      type: IRNodeTypes.SET_EVENT,
+      element: 1,
+      key: {
+        type: NodeTypes.SIMPLE_EXPRESSION,
+        content: 'event(foo)',
+        isStatic: false,
+      },
+      value: {
+        type: NodeTypes.SIMPLE_EXPRESSION,
+        content: 'handler',
+        isStatic: false,
+      },
+    })
+
+    expect(code).matchSnapshot()
+  })
+
+  test('should wrap as function if expression is inline statement', () => {
+    const { code, ir } = compileWithVOn(`<div @click="i++"/>`)
+
+    expect(ir.vaporHelpers).contains('on')
+    expect(ir.helpers.size).toBe(0)
+    expect(ir.effect).toEqual([])
+
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        element: 1,
+        value: {
+          type: NodeTypes.SIMPLE_EXPRESSION,
+          content: 'i++',
+          isStatic: false,
+        },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+    expect(code).contains('_on(n1, "click", $event => (_ctx.i++))')
+  })
+
+  test('should handle multiple inline statement', () => {
+    const { ir, code } = compileWithVOn(`<div @click="foo();bar()"/>`)
+
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: { content: 'foo();bar()' },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+    // should wrap with `{` for multiple statements
+    // in this case the return value is discarded and the behavior is
+    // consistent with 2.x
+    expect(code).contains('_on(n1, "click", $event => {_ctx.foo();_ctx.bar()})')
+  })
+
+  test('should handle multi-line statement', () => {
+    const { code, ir } = compileWithVOn(`<div @click="\nfoo();\nbar()\n"/>`)
+
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: { content: '\nfoo();\nbar()\n' },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+    // should wrap with `{` for multiple statements
+    // in this case the return value is discarded and the behavior is
+    // consistent with 2.x
+    expect(code).contains(
+      '_on(n1, "click", $event => {\n_ctx.foo();\n_ctx.bar()\n})',
+    )
+  })
+
+  test('inline statement w/ prefixIdentifiers: true', () => {
+    const { code, ir } = compileWithVOn(`<div @click="foo($event)"/>`, {
+      prefixIdentifiers: true,
+    })
+
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: { content: 'foo($event)' },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+    // should NOT prefix $event
+    expect(code).contains('_on(n1, "click", $event => (_ctx.foo($event)))')
+  })
+
+  test('multiple inline statements w/ prefixIdentifiers: true', () => {
+    const { ir, code } = compileWithVOn(`<div @click="foo($event);bar()"/>`, {
+      prefixIdentifiers: true,
+    })
+
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: { content: 'foo($event);bar()' },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+    // should NOT prefix $event
+    expect(code).contains(
+      '_on(n1, "click", $event => {_ctx.foo($event);_ctx.bar()})',
+    )
+  })
+
+  test('should NOT wrap as function if expression is already function expression', () => {
+    const { code, ir } = compileWithVOn(`<div @click="$event => foo($event)"/>`)
+
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: { content: '$event => foo($event)' },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+    expect(code).contains('_on(n1, "click", $event => _ctx.foo($event))')
+  })
+
+  test.fails(
     'should NOT wrap as function if expression is already function expression (with Typescript)',
+    () => {
+      const { ir, code } = compileWithVOn(
+        `<div @click="(e: any): any => foo(e)"/>`,
+        { expressionPlugins: ['typescript'] },
+      )
+
+      expect(ir.operation).toMatchObject([
+        {
+          type: IRNodeTypes.SET_EVENT,
+          value: { content: '(e: any): any => foo(e)' },
+        },
+      ])
+
+      expect(code).matchSnapshot()
+      expect(code).contains('_on(n1, "click", e => _ctx.foo(e))')
+    },
   )
-  test.todo(
-    'should NOT wrap as function if expression is already function expression (with newlines)',
-  )
-  test.todo(
-    'should NOT wrap as function if expression is already function expression (with newlines + function keyword)',
-  )
-  test.todo(
-    'should NOT wrap as function if expression is complex member expression',
-  )
-  test.todo('complex member expression w/ prefixIdentifiers: true')
-  test.todo('function expression w/ prefixIdentifiers: true')
+
+  test('should NOT wrap as function if expression is already function expression (with newlines)', () => {
+    const { ir, code } = compileWithVOn(
+      `<div @click="
+      $event => {
+        foo($event)
+      }
+    "/>`,
+    )
+
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: {
+          content: `
+      $event => {
+        foo($event)
+      }
+    `,
+        },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+  })
+
+  test('should NOT add a prefix to $event if the expression is a function expression', () => {
+    const { ir, code } = compileWithVOn(
+      `<div @click="$event => {i++;foo($event)}"></div>`,
+      {
+        prefixIdentifiers: true,
+      },
+    )
+
+    expect(ir.operation[0]).toMatchObject({
+      type: IRNodeTypes.SET_EVENT,
+      value: { content: '$event => {i++;foo($event)}' },
+    })
+
+    expect(code).matchSnapshot()
+  })
+
+  test('should NOT wrap as function if expression is complex member expression', () => {
+    const { ir, code } = compileWithVOn(`<div @click="a['b' + c]"/>`)
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: { content: `a['b' + c]` },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+  })
+
+  test('complex member expression w/ prefixIdentifiers: true', () => {
+    const { ir, code } = compileWithVOn(`<div @click="a['b' + c]"/>`)
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: { content: `a['b' + c]` },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      `_on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args)))`,
+    )
+  })
+
+  test('function expression w/ prefixIdentifiers: true', () => {
+    const { code, ir } = compileWithVOn(`<div @click="e => foo(e)"/>`, {
+      prefixIdentifiers: true,
+    })
+
+    expect(ir.operation).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        value: { content: `e => foo(e)` },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+    expect(code).contains('_on(n1, "click", e => _ctx.foo(e))')
+  })
 
   test('should error if no expression AND no modifier', () => {
     const onError = vi.fn()
@@ -366,14 +590,40 @@ describe('v-on', () => {
     expect(ir.operation).toMatchObject([
       {
         type: IRNodeTypes.SET_EVENT,
-        modifiers: { keys: ['left'] },
+        modifiers: {
+          keys: ['left'],
+          nonKeys: [],
+          options: [],
+        },
       },
     ])
 
     expect(code).matchSnapshot()
   })
 
-  test.todo('should wrap both for dynamic key event w/ left/right modifiers')
+  test('should wrap both for dynamic key event w/ left/right modifiers', () => {
+    const { code, ir } = compileWithVOn(`<div @[e].left="test"/>`, {
+      prefixIdentifiers: true,
+    })
+
+    expect(ir.effect[0].operations).toMatchObject([
+      {
+        type: IRNodeTypes.SET_EVENT,
+        key: {
+          type: NodeTypes.SIMPLE_EXPRESSION,
+          content: 'e',
+          isStatic: false,
+        },
+        modifiers: {
+          keys: ['left'],
+          nonKeys: ['left'],
+          options: [],
+        },
+      },
+    ])
+
+    expect(code).matchSnapshot()
+  })
 
   test('should transform click.right', () => {
     const { code, ir } = compileWithVOn(`<div @click.right="test"/>`)
index 6caf9a0d06443d4c206c5f151d57fdaff3824bbe..262ba0898d9ff723fde819db0bf732ab8c0fef13 100644 (file)
@@ -8,6 +8,7 @@ import {
   advancePositionWithClone,
   advancePositionWithMutation,
   createSimpleExpression,
+  isMemberExpression,
   isSimpleIdentifier,
   locStub,
   walkIdentifiers,
@@ -33,6 +34,10 @@ import { SourceMapGenerator } from 'source-map-js'
 import { camelize, isGloballyAllowed, isString, makeMap } from '@vue/shared'
 import type { Identifier } from '@babel/types'
 
+// TODO: share this with compiler-core
+const fnExpRE =
+  /^\s*([\w$_]+|(async\s*)?\([^)]*?\))\s*(:[^=]+)?=>|^\s*(async\s+)?function(?:\s+[\w$]+)?\s*\(/
+
 // remove when stable
 // @ts-expect-error
 function checkNever(x: never): never {}
@@ -508,15 +513,7 @@ function genSetEvent(oper: SetEventIRNode, context: CodegenContext) {
 
       ;(keys.length ? pushWithKeys : pushNoop)(() =>
         (nonKeys.length ? pushWithModifiers : pushNoop)(() => {
-          if (oper.value && oper.value.content.trim()) {
-            push('(...args) => (')
-            genExpression(oper.value, context)
-            push(' && ')
-            genExpression(oper.value, context)
-            push('(...args))')
-          } else {
-            push('() => {}')
-          }
+          genEventHandler()
         }),
       )
     },
@@ -524,6 +521,37 @@ function genSetEvent(oper: SetEventIRNode, context: CodegenContext) {
     !!options.length &&
       (() => push(`{ ${options.map((v) => `${v}: true`).join(', ')} }`)),
   )
+
+  function genEventHandler() {
+    const exp = oper.value
+    if (exp && exp.content.trim()) {
+      const isMemberExp = isMemberExpression(exp.content, {
+        // TODO: expression plugins
+        expressionPlugins: [],
+      })
+      const isInlineStatement = !(isMemberExp || fnExpRE.test(exp.content))
+      const hasMultipleStatements = exp.content.includes(`;`)
+
+      if (isInlineStatement) {
+        push('$event => ')
+        push(hasMultipleStatements ? '{' : '(')
+        const knownIds = Object.create(null)
+        knownIds['$event'] = 1
+        genExpression(exp, context, knownIds)
+        push(hasMultipleStatements ? '}' : ')')
+      } else if (isMemberExp) {
+        push('(...args) => (')
+        genExpression(exp, context)
+        push(' && ')
+        genExpression(exp, context)
+        push('(...args))')
+      } else {
+        genExpression(exp, context)
+      }
+    } else {
+      push('() => {}')
+    }
+  }
 }
 
 function genWithDirective(oper: WithDirectiveIRNode, context: CodegenContext) {
@@ -588,7 +616,11 @@ function genArrayExpression(elements: string[]) {
 
 const isLiteralWhitelisted = /*#__PURE__*/ makeMap('true,false,null,this')
 
-function genExpression(node: IRExpression, context: CodegenContext): void {
+function genExpression(
+  node: IRExpression,
+  context: CodegenContext,
+  knownIds: Record<string, number> = Object.create(null),
+): void {
   const { push } = context
   if (isString(node)) return push(node)
 
@@ -616,10 +648,13 @@ function genExpression(node: IRExpression, context: CodegenContext): void {
   const ids: Identifier[] = []
   walkIdentifiers(
     ast!,
-    (id) => {
+    (id, parent, parentStack, isReference, isLocal) => {
+      if (isLocal) return
       ids.push(id)
     },
     true,
+    [],
+    knownIds,
   )
   if (ids.length) {
     ids.sort((a, b) => a.start! - b.start!)
index a8db2454a80f4947cc2c9e2a6193f4505ad6b9e9..8fe9c18c63aa8ec3a25b05b7846b0626e673fc3b 100644 (file)
@@ -71,6 +71,7 @@ export {
 export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants'
 export {
   baseWatch,
+  getCurrentEffect,
   onEffectCleanup,
   traverse,
   BaseWatchErrorCodes,
index c740912ef1e4af99ffe950777d4b9b362c08c35e..805fbae0eb995381ae890381614cbd359a16200a 100644 (file)
@@ -29,6 +29,7 @@ export {
   // effect
   stop,
   ReactiveEffect,
+  getCurrentEffect,
   onEffectCleanup,
   // effect scope
   effectScope,
index eff58c94119a8c00b72a9c92c83e6e4da09f87f8..a25f47cc8959c86011752e12df2e9dd4197240b4 100644 (file)
@@ -1,3 +1,5 @@
+import { getCurrentEffect, onEffectCleanup } from '@vue/reactivity'
+
 export function on(
   el: HTMLElement,
   event: string,
@@ -5,4 +7,7 @@ export function on(
   options?: AddEventListenerOptions,
 ) {
   el.addEventListener(event, handler, options)
+  if (getCurrentEffect()) {
+    onEffectCleanup(() => el.removeEventListener(event, handler, options))
+  }
 }
index 01f14883fb09cfc386878568b48985df28ff827f..0eb8560234ad64f773347ec806773b7969609f98 100644 (file)
@@ -1,7 +1,11 @@
 <script setup lang="ts">
+import { ref } from 'vue/vapor'
 const handleClick = () => {
   console.log('Hello, Vapor!')
 }
+
+const i = ref(0)
+const event = ref('click')
 </script>
 
 <template>
@@ -12,4 +16,12 @@ const handleClick = () => {
   <form>
     <button @click.prevent="handleClick">no submit</button>
   </form>
+
+  <div>
+    {{ i }}
+    <button @[event].prevent="i++">Add by {{ event }}</button>
+    <button @click="event = event === 'click' ? 'contextmenu' : 'click'">
+      Change Event
+    </button>
+  </div>
 </template>