]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): vapor TransitionGroup (#13019)
authoredison <daiwei521@126.com>
Tue, 11 Mar 2025 13:37:33 +0000 (21:37 +0800)
committerGitHub <noreply@github.com>
Tue, 11 Mar 2025 13:37:33 +0000 (21:37 +0800)
* wip: save

* wip: save

* wip: handle tag prop and attrs fallthrough

* test: add e2e tests

* [autofix.ci] apply automated fixes

* wip: add more tests

* [autofix.ci] apply automated fixes

* wip: handle vdom interop

* [autofix.ci] apply automated fixes

* wip: vapor interop + filter out reserved props

* [autofix.ci] apply automated fixes

* fix: tests

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
27 files changed:
packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts [new file with mode: 0644]
packages-private/vapor-e2e-test/__tests__/transition.spec.ts
packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
packages-private/vapor-e2e-test/index.html
packages-private/vapor-e2e-test/interop/App.vue
packages-private/vapor-e2e-test/transition-group/App.vue [new file with mode: 0644]
packages-private/vapor-e2e-test/transition-group/components/VaporComp.vue [new file with mode: 0644]
packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue [new file with mode: 0644]
packages-private/vapor-e2e-test/transition-group/index.html [new file with mode: 0644]
packages-private/vapor-e2e-test/transition-group/main.ts [new file with mode: 0644]
packages-private/vapor-e2e-test/transition/App.vue
packages-private/vapor-e2e-test/transition/components/VaporSlot.vue [new file with mode: 0644]
packages-private/vapor-e2e-test/transition/main.ts
packages-private/vapor-e2e-test/transition/style.css
packages-private/vapor-e2e-test/vite.config.ts
packages/compiler-vapor/src/transforms/transformElement.ts
packages/compiler-vapor/src/utils.ts
packages/runtime-dom/src/components/TransitionGroup.ts
packages/runtime-dom/src/index.ts
packages/runtime-vapor/src/apiCreateFor.ts
packages/runtime-vapor/src/block.ts
packages/runtime-vapor/src/components/Transition.ts
packages/runtime-vapor/src/components/TransitionGroup.ts [new file with mode: 0644]
packages/runtime-vapor/src/index.ts
packages/runtime-vapor/src/vdomInterop.ts
packages/vue/__tests__/e2e/style.css [new file with mode: 0644]
packages/vue/__tests__/e2e/transition.html

diff --git a/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts b/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts
new file mode 100644 (file)
index 0000000..ba050f0
--- /dev/null
@@ -0,0 +1,406 @@
+import path from 'node:path'
+import {
+  E2E_TIMEOUT,
+  setupPuppeteer,
+} from '../../../packages/vue/__tests__/e2e/e2eUtils'
+import connect from 'connect'
+import sirv from 'sirv'
+import { expect } from 'vitest'
+const { page, nextFrame, timeout, html, transitionStart } = setupPuppeteer()
+
+const duration = process.env.CI ? 200 : 50
+const buffer = process.env.CI ? 50 : 20
+const transitionFinish = (time = duration) => timeout(time + buffer)
+
+describe('vapor transition-group', () => {
+  let server: any
+  const port = '8196'
+  beforeAll(() => {
+    server = connect()
+      .use(sirv(path.resolve(import.meta.dirname, '../dist')))
+      .listen(port)
+    process.on('SIGTERM', () => server && server.close())
+  })
+
+  afterAll(() => {
+    server.close()
+  })
+
+  beforeEach(async () => {
+    const baseUrl = `http://localhost:${port}/transition-group/`
+    await page().goto(baseUrl)
+    await page().waitForSelector('#app')
+  })
+
+  test(
+    'enter',
+    async () => {
+      const btnSelector = '.enter > button'
+      const containerSelector = '.enter > div'
+
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test test-enter-from test-enter-active">d</div>` +
+          `<div class="test test-enter-from test-enter-active">e</div>`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test test-enter-active test-enter-to">d</div>` +
+          `<div class="test test-enter-active test-enter-to">e</div>`,
+      )
+
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test">d</div>` +
+          `<div class="test">e</div>`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'leave',
+    async () => {
+      const btnSelector = '.leave > button'
+      const containerSelector = '.leave > div'
+
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `<div class="test test-leave-from test-leave-active">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test test-leave-from test-leave-active">c</div>`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test test-leave-active test-leave-to">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test test-leave-active test-leave-to">c</div>`,
+      )
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(`<div class="test">b</div>`)
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'enter + leave',
+    async () => {
+      const btnSelector = '.enter-leave > button'
+      const containerSelector = '.enter-leave > div'
+
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `<div class="test test-leave-from test-leave-active">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test test-enter-from test-enter-active">d</div>`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test test-leave-active test-leave-to">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test test-enter-active test-enter-to">d</div>`,
+      )
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test">d</div>`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'appear',
+    async () => {
+      const btnSelector = '.appear > button'
+      const containerSelector = '.appear > div'
+
+      expect(await html('.appear')).toBe(`<button>appear button</button>`)
+
+      await page().evaluate(() => {
+        return (window as any).setAppear()
+      })
+
+      // appear
+      expect(await html(containerSelector)).toBe(
+        `<div class="test test-appear-from test-appear-active">a</div>` +
+          `<div class="test test-appear-from test-appear-active">b</div>` +
+          `<div class="test test-appear-from test-appear-active">c</div>`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test test-appear-active test-appear-to">a</div>` +
+          `<div class="test test-appear-active test-appear-to">b</div>` +
+          `<div class="test test-appear-active test-appear-to">c</div>`,
+      )
+
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>`,
+      )
+
+      // enter
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test test-enter-from test-enter-active">d</div>` +
+          `<div class="test test-enter-from test-enter-active">e</div>`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test test-enter-active test-enter-to">d</div>` +
+          `<div class="test test-enter-active test-enter-to">e</div>`,
+      )
+
+      await transitionFinish()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>` +
+          `<div class="test">d</div>` +
+          `<div class="test">e</div>`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test(
+    'move',
+    async () => {
+      const btnSelector = '.move > button'
+      const containerSelector = '.move > div'
+
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `<div class="test group-enter-from group-enter-active">d</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test group-move" style="">a</div>` +
+          `<div class="test group-leave-from group-leave-active group-move" style="">c</div>`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test group-enter-active group-enter-to">d</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test group-move" style="">a</div>` +
+          `<div class="test group-leave-active group-move group-leave-to" style="">c</div>`,
+      )
+      await transitionFinish(duration * 2)
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">d</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test" style="">a</div>`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
+  test('dynamic name', async () => {
+    const btnSelector = '.dynamic-name button.toggleBtn'
+    const btnChangeName = '.dynamic-name button.changeNameBtn'
+    const containerSelector = '.dynamic-name > div'
+
+    expect(await html(containerSelector)).toBe(
+      `<div>a</div>` + `<div>b</div>` + `<div>c</div>`,
+    )
+
+    // invalid name
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(`<div>b</div>` + `<div>c</div>` + `<div>a</div>`)
+
+    // change name
+    expect(
+      (await transitionStart(btnChangeName, containerSelector)).innerHTML,
+    ).toBe(
+      `<div class="group-move" style="">a</div>` +
+        `<div class="group-move" style="">b</div>` +
+        `<div class="group-move" style="">c</div>`,
+    )
+
+    await transitionFinish()
+    expect(await html(containerSelector)).toBe(
+      `<div class="" style="">a</div>` +
+        `<div class="" style="">b</div>` +
+        `<div class="" style="">c</div>`,
+    )
+  })
+
+  test('events', async () => {
+    const btnSelector = '.events > button'
+    const containerSelector = '.events > div'
+
+    expect(await html('.events')).toBe(`<button>events button</button>`)
+
+    await page().evaluate(() => {
+      return (window as any).setAppear()
+    })
+
+    // appear
+    expect(await html(containerSelector)).toBe(
+      `<div class="test test-appear-from test-appear-active">a</div>` +
+        `<div class="test test-appear-from test-appear-active">b</div>` +
+        `<div class="test test-appear-from test-appear-active">c</div>`,
+    )
+    await nextFrame()
+    expect(await html(containerSelector)).toBe(
+      `<div class="test test-appear-active test-appear-to">a</div>` +
+        `<div class="test test-appear-active test-appear-to">b</div>` +
+        `<div class="test test-appear-active test-appear-to">c</div>`,
+    )
+
+    let calls = await page().evaluate(() => {
+      return (window as any).getCalls()
+    })
+    expect(calls).toContain('beforeAppear')
+    expect(calls).toContain('onAppear')
+    expect(calls).not.toContain('afterAppear')
+
+    await transitionFinish()
+    expect(await html(containerSelector)).toBe(
+      `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `<div class="test">c</div>`,
+    )
+
+    expect(
+      await page().evaluate(() => {
+        return (window as any).getCalls()
+      }),
+    ).toContain('afterAppear')
+
+    // enter + leave
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(
+      `<div class="test test-leave-from test-leave-active">a</div>` +
+        `<div class="test">b</div>` +
+        `<div class="test">c</div>` +
+        `<div class="test test-enter-from test-enter-active">d</div>`,
+    )
+
+    calls = await page().evaluate(() => {
+      return (window as any).getCalls()
+    })
+    expect(calls).toContain('beforeLeave')
+    expect(calls).toContain('onLeave')
+    expect(calls).not.toContain('afterLeave')
+    expect(calls).toContain('beforeEnter')
+    expect(calls).toContain('onEnter')
+    expect(calls).not.toContain('afterEnter')
+
+    await nextFrame()
+    expect(await html(containerSelector)).toBe(
+      `<div class="test test-leave-active test-leave-to">a</div>` +
+        `<div class="test">b</div>` +
+        `<div class="test">c</div>` +
+        `<div class="test test-enter-active test-enter-to">d</div>`,
+    )
+    calls = await page().evaluate(() => {
+      return (window as any).getCalls()
+    })
+    expect(calls).not.toContain('afterLeave')
+    expect(calls).not.toContain('afterEnter')
+
+    await transitionFinish()
+    expect(await html(containerSelector)).toBe(
+      `<div class="test">b</div>` +
+        `<div class="test">c</div>` +
+        `<div class="test">d</div>`,
+    )
+
+    calls = await page().evaluate(() => {
+      return (window as any).getCalls()
+    })
+    expect(calls).toContain('afterLeave')
+    expect(calls).toContain('afterEnter')
+  })
+
+  test('interop: render vdom component', async () => {
+    const btnSelector = '.interop > button'
+    const containerSelector = '.interop > div'
+
+    expect(await html(containerSelector)).toBe(
+      `<div><div>a</div></div>` +
+        `<div><div>b</div></div>` +
+        `<div><div>c</div></div>`,
+    )
+
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(
+      `<div class="test-leave-from test-leave-active"><div>a</div></div>` +
+        `<div class="test-move" style=""><div>b</div></div>` +
+        `<div class="test-move" style=""><div>c</div></div>` +
+        `<div class="test-enter-from test-enter-active"><div>d</div></div>`,
+    )
+
+    await nextFrame()
+    expect(await html(containerSelector)).toBe(
+      `<div class="test-leave-active test-leave-to"><div>a</div></div>` +
+        `<div class="test-move" style=""><div>b</div></div>` +
+        `<div class="test-move" style=""><div>c</div></div>` +
+        `<div class="test-enter-active test-enter-to"><div>d</div></div>`,
+    )
+
+    await transitionFinish()
+    expect(await html(containerSelector)).toBe(
+      `<div class="" style=""><div>b</div></div>` +
+        `<div class="" style=""><div>c</div></div>` +
+        `<div class=""><div>d</div></div>`,
+    )
+  })
+})
index 19770e7b9bbe354917a9f0bf9a21035669ef6a51..ebc9567b0c15316f71a2c8eaa33d32ca45bb168b 100644 (file)
@@ -130,7 +130,7 @@ describe('vapor transition', () => {
 
       expect(calls).toStrictEqual([
         'beforeAppear',
-        'onEnter',
+        'onAppear',
         'afterAppear',
         'beforeLeave',
         'onLeave',
index a6eb410fbb4188e191629e4e64925a4b4c533086..e05f06e1abd7b999664428deafca228b479a5d03 100644 (file)
@@ -105,20 +105,18 @@ describe('vdom / vapor interop', () => {
         const btnSelector = '.trans-vapor > button'
         const containerSelector = '.trans-vapor > div'
 
-        expect(await html(containerSelector)).toBe(
-          `<div key="0">vapor compA</div>`,
-        )
+        expect(await html(containerSelector)).toBe(`<div>vapor compA</div>`)
 
         // comp leave
         expect(
           (await transitionStart(btnSelector, containerSelector)).innerHTML,
         ).toBe(
-          `<div key="0" class="v-leave-from v-leave-active">vapor compA</div><!---->`,
+          `<div class="v-leave-from v-leave-active">vapor compA</div><!---->`,
         )
 
         await nextFrame()
         expect(await html(containerSelector)).toBe(
-          `<div key="0" class="v-leave-active v-leave-to">vapor compA</div><!---->`,
+          `<div class="v-leave-active v-leave-to">vapor compA</div><!---->`,
         )
 
         await transitionFinish()
@@ -127,18 +125,16 @@ describe('vdom / vapor interop', () => {
         // comp enter
         expect(
           (await transitionStart(btnSelector, containerSelector)).innerHTML,
-        ).toBe(
-          `<div key="0" class="v-enter-from v-enter-active">vapor compA</div>`,
-        )
+        ).toBe(`<div class="v-enter-from v-enter-active">vapor compA</div>`)
 
         await nextFrame()
         expect(await html(containerSelector)).toBe(
-          `<div key="0" class="v-enter-active v-enter-to">vapor compA</div>`,
+          `<div class="v-enter-active v-enter-to">vapor compA</div>`,
         )
 
         await transitionFinish()
         expect(await html(containerSelector)).toBe(
-          `<div key="0" class="">vapor compA</div>`,
+          `<div class="">vapor compA</div>`,
         )
       },
       E2E_TIMEOUT,
@@ -214,4 +210,50 @@ describe('vdom / vapor interop', () => {
       E2E_TIMEOUT,
     )
   })
+
+  describe('vdom transition-group', () => {
+    test(
+      'render vapor component',
+      async () => {
+        const btnSelector = '.trans-group-vapor > button'
+        const containerSelector = '.trans-group-vapor > div'
+
+        expect(await html(containerSelector)).toBe(
+          `<div><div>a</div></div>` +
+            `<div><div>b</div></div>` +
+            `<div><div>c</div></div>`,
+        )
+
+        // insert
+        expect(
+          (await transitionStart(btnSelector, containerSelector)).innerHTML,
+        ).toBe(
+          `<div><div>a</div></div>` +
+            `<div><div>b</div></div>` +
+            `<div><div>c</div></div>` +
+            `<div class="test-enter-from test-enter-active"><div>d</div></div>` +
+            `<div class="test-enter-from test-enter-active"><div>e</div></div>`,
+        )
+
+        await nextFrame()
+        expect(await html(containerSelector)).toBe(
+          `<div><div>a</div></div>` +
+            `<div><div>b</div></div>` +
+            `<div><div>c</div></div>` +
+            `<div class="test-enter-active test-enter-to"><div>d</div></div>` +
+            `<div class="test-enter-active test-enter-to"><div>e</div></div>`,
+        )
+
+        await transitionFinish()
+        expect(await html(containerSelector)).toBe(
+          `<div><div>a</div></div>` +
+            `<div><div>b</div></div>` +
+            `<div><div>c</div></div>` +
+            `<div class=""><div>d</div></div>` +
+            `<div class=""><div>e</div></div>`,
+        )
+      },
+      E2E_TIMEOUT,
+    )
+  })
 })
index 160e2125d336c8799012c778129d04ba5dbf223c..09ea6aa607a4283ce54ff83a4fd91428153002ff 100644 (file)
@@ -1,3 +1,11 @@
 <a href="/interop/">VDOM / Vapor interop</a>
 <a href="/todomvc/">Vapor TodoMVC</a>
 <a href="/transition/">Vapor Transition</a>
+<a href="/transition-group/">Vapor TransitionGroup</a>
+
+<style>
+  a {
+    display: block;
+    margin: 10px;
+  }
+</style>
index f29df3c80cd2ac7255a043e71d1802b916fbbf4b..8cf42e475498f62f71155efdc4dc147117f23fa4 100644 (file)
@@ -3,6 +3,7 @@ import { ref, shallowRef } from 'vue'
 import VaporComp from './VaporComp.vue'
 import VaporCompA from '../transition/components/VaporCompA.vue'
 import VdomComp from '../transition/components/VdomComp.vue'
+import VaporSlot from '../transition/components/VaporSlot.vue'
 
 const msg = ref('hello')
 const passSlot = ref(true)
@@ -13,6 +14,9 @@ function toggleInteropComponent() {
   interopComponent.value =
     interopComponent.value === VaporCompA ? VdomComp : VaporCompA
 }
+
+const items = ref(['a', 'b', 'c'])
+const enterClick = () => items.value.push('d', 'e')
 </script>
 
 <template>
@@ -52,4 +56,17 @@ function toggleInteropComponent() {
       </div>
     </div>
   </div>
+  <!-- transition-group interop -->
+  <div>
+    <div class="trans-group-vapor">
+      <button @click="enterClick">insert items</button>
+      <div>
+        <transition-group name="test">
+          <VaporSlot v-for="item in items" :key="item">
+            <div>{{ item }}</div>
+          </VaporSlot>
+        </transition-group>
+      </div>
+    </div>
+  </div>
 </template>
diff --git a/packages-private/vapor-e2e-test/transition-group/App.vue b/packages-private/vapor-e2e-test/transition-group/App.vue
new file mode 100644 (file)
index 0000000..5577574
--- /dev/null
@@ -0,0 +1,145 @@
+<script setup vapor>
+import { ref } from 'vue'
+import VdomComp from './components/VdomComp.vue'
+
+const items = ref(['a', 'b', 'c'])
+const enterClick = () => items.value.push('d', 'e')
+const leaveClick = () => (items.value = ['b'])
+const enterLeaveClick = () => (items.value = ['b', 'c', 'd'])
+const appear = ref(false)
+window.setAppear = () => (appear.value = true)
+const moveClick = () => (items.value = ['d', 'b', 'a'])
+
+const name = ref('invalid')
+const dynamicClick = () => (items.value = ['b', 'c', 'a'])
+const changeName = () => {
+  name.value = 'group'
+  items.value = ['a', 'b', 'c']
+}
+
+let calls = []
+window.getCalls = () => {
+  const ret = calls.slice()
+  calls = []
+  return ret
+}
+const eventsClick = () => (items.value = ['b', 'c', 'd'])
+
+const interopClick = () => (items.value = ['b', 'c', 'd'])
+</script>
+
+<template>
+  <div class="transition-group-container">
+    <div class="enter">
+      <button @click="enterClick">enter button</button>
+      <div>
+        <transition-group name="test">
+          <div v-for="item in items" :key="item" class="test">{{ item }}</div>
+        </transition-group>
+      </div>
+    </div>
+    <div class="leave">
+      <button @click="leaveClick">leave button</button>
+      <div>
+        <transition-group name="test">
+          <div v-for="item in items" :key="item" class="test">{{ item }}</div>
+        </transition-group>
+      </div>
+    </div>
+    <div class="enter-leave">
+      <button @click="enterLeaveClick">enter-leave button</button>
+      <div>
+        <transition-group name="test">
+          <div v-for="item in items" :key="item" class="test">{{ item }}</div>
+        </transition-group>
+      </div>
+    </div>
+    <div class="appear">
+      <button @click="enterClick">appear button</button>
+      <div v-if="appear">
+        <transition-group
+          appear
+          appear-from-class="test-appear-from"
+          appear-to-class="test-appear-to"
+          appear-active-class="test-appear-active"
+          name="test"
+        >
+          <div v-for="item in items" :key="item" class="test">{{ item }}</div>
+        </transition-group>
+      </div>
+    </div>
+    <div class="move">
+      <button @click="moveClick">move button</button>
+      <div>
+        <transition-group name="group">
+          <div v-for="item in items" :key="item" class="test">{{ item }}</div>
+        </transition-group>
+      </div>
+    </div>
+    <div class="dynamic-name">
+      <button class="toggleBtn" @click="dynamicClick">dynamic button</button>
+      <button class="changeNameBtn" @click="changeName">change name</button>
+      <div>
+        <transition-group :name="name">
+          <div v-for="item in items" :key="item">{{ item }}</div>
+        </transition-group>
+      </div>
+    </div>
+    <div class="events">
+      <button @click="eventsClick">events button</button>
+      <div v-if="appear">
+        <transition-group
+          name="test"
+          appear
+          appear-from-class="test-appear-from"
+          appear-to-class="test-appear-to"
+          appear-active-class="test-appear-active"
+          @beforeEnter="() => calls.push('beforeEnter')"
+          @enter="() => calls.push('onEnter')"
+          @afterEnter="() => calls.push('afterEnter')"
+          @beforeLeave="() => calls.push('beforeLeave')"
+          @leave="() => calls.push('onLeave')"
+          @afterLeave="() => calls.push('afterLeave')"
+          @beforeAppear="() => calls.push('beforeAppear')"
+          @appear="() => calls.push('onAppear')"
+          @afterAppear="() => calls.push('afterAppear')"
+        >
+          <div v-for="item in items" :key="item" class="test">{{ item }}</div>
+        </transition-group>
+      </div>
+    </div>
+    <div class="interop">
+      <button @click="interopClick">interop button</button>
+      <div>
+        <transition-group name="test">
+          <VdomComp v-for="item in items" :key="item">
+            <div>{{ item }}</div>
+          </VdomComp>
+        </transition-group>
+      </div>
+    </div>
+  </div>
+</template>
+<style>
+.transition-group-container > div {
+  padding: 15px;
+  border: 1px solid #f7f7f7;
+  margin-top: 15px;
+}
+
+.test-move,
+.test-enter-active,
+.test-leave-active {
+  transition: all 50ms cubic-bezier(0.55, 0, 0.1, 1);
+}
+
+.test-enter-from,
+.test-leave-to {
+  opacity: 0;
+  transform: scaleY(0.01) translate(30px, 0);
+}
+
+.test-leave-active {
+  position: absolute;
+}
+</style>
diff --git a/packages-private/vapor-e2e-test/transition-group/components/VaporComp.vue b/packages-private/vapor-e2e-test/transition-group/components/VaporComp.vue
new file mode 100644 (file)
index 0000000..906795d
--- /dev/null
@@ -0,0 +1,9 @@
+<script vapor>
+const msg = 'vapor comp'
+</script>
+
+<template>
+  <div>
+    <slot />
+  </div>
+</template>
diff --git a/packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue b/packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue
new file mode 100644 (file)
index 0000000..afd7d55
--- /dev/null
@@ -0,0 +1,9 @@
+<script setup>
+const msg = 'vdom comp'
+</script>
+
+<template>
+  <div>
+    <slot />
+  </div>
+</template>
diff --git a/packages-private/vapor-e2e-test/transition-group/index.html b/packages-private/vapor-e2e-test/transition-group/index.html
new file mode 100644 (file)
index 0000000..79052a0
--- /dev/null
@@ -0,0 +1,2 @@
+<script type="module" src="./main.ts"></script>
+<div id="app"></div>
diff --git a/packages-private/vapor-e2e-test/transition-group/main.ts b/packages-private/vapor-e2e-test/transition-group/main.ts
new file mode 100644 (file)
index 0000000..efa06a2
--- /dev/null
@@ -0,0 +1,5 @@
+import { createVaporApp, vaporInteropPlugin } from 'vue'
+import App from './App.vue'
+import '../../../packages/vue/__tests__/e2e/style.css'
+
+createVaporApp(App).use(vaporInteropPlugin).mount('#app')
index 057bb0a229e927693c513880295853b27616aede..b8470c10749da007eb6705e369f9b6eb138d247a 100644 (file)
@@ -26,78 +26,80 @@ function toggleInteropComponent() {
 </script>
 
 <template>
-  <div class="vshow">
-    <button @click="show = !show">Show</button>
-    <Transition>
-      <h1 v-show="show">vShow</h1>
-    </Transition>
-  </div>
-  <div class="vif">
-    <button @click="toggle = !toggle">Toggle</button>
-    <Transition
-      appear
-      @beforeEnter="() => calls.push('beforeEnter')"
-      @enter="() => calls.push('onEnter')"
-      @afterEnter="() => calls.push('afterEnter')"
-      @beforeLeave="() => calls.push('beforeLeave')"
-      @leave="() => calls.push('onLeave')"
-      @afterLeave="() => calls.push('afterLeave')"
-      @beforeAppear="() => calls.push('beforeAppear')"
-      @appear="() => calls.push('onAppear')"
-      @afterAppear="() => calls.push('afterAppear')"
-    >
-      <h1 v-if="toggle">vIf</h1>
-    </Transition>
-  </div>
-  <div class="keyed">
-    <button @click="count++">inc</button>
-    <Transition>
-      <h1 style="position: absolute" :key="count">{{ count }}</h1>
-    </Transition>
-  </div>
-  <div class="out-in">
-    <button @click="toggleComponent">toggle out-in</button>
-    <div>
-      <Transition name="fade" mode="out-in">
-        <component :is="activeComponent"></component>
+  <div class="transition-container">
+    <div class="vshow">
+      <button @click="show = !show">Show</button>
+      <Transition>
+        <h1 v-show="show">vShow</h1>
       </Transition>
     </div>
-  </div>
-  <div class="in-out">
-    <button @click="toggleComponent">toggle in-out</button>
-    <div>
-      <Transition name="fade" mode="in-out">
-        <component :is="activeComponent"></component>
+    <div class="vif">
+      <button @click="toggle = !toggle">Toggle</button>
+      <Transition
+        appear
+        @beforeEnter="() => calls.push('beforeEnter')"
+        @enter="() => calls.push('onEnter')"
+        @afterEnter="() => calls.push('afterEnter')"
+        @beforeLeave="() => calls.push('beforeLeave')"
+        @leave="() => calls.push('onLeave')"
+        @afterLeave="() => calls.push('afterLeave')"
+        @beforeAppear="() => calls.push('beforeAppear')"
+        @appear="() => calls.push('onAppear')"
+        @afterAppear="() => calls.push('afterAppear')"
+      >
+        <h1 v-if="toggle">vIf</h1>
       </Transition>
     </div>
-  </div>
-  <div class="vdom">
-    <button @click="toggleVdom = !toggleVdom">toggle vdom component</button>
-    <div>
+    <div class="keyed">
+      <button @click="count++">inc</button>
       <Transition>
-        <VDomComp v-if="toggleVdom" />
+        <h1 style="position: absolute" :key="count">{{ count }}</h1>
       </Transition>
     </div>
-  </div>
-  <div class="vdom-vapor-out-in">
-    <button @click="toggleInteropComponent">
-      switch between vdom/vapor component out-in mode
-    </button>
-    <div>
-      <Transition name="fade" mode="out-in">
-        <component :is="interopComponent"></component>
-      </Transition>
+    <div class="out-in">
+      <button @click="toggleComponent">toggle out-in</button>
+      <div>
+        <Transition name="fade" mode="out-in">
+          <component :is="activeComponent"></component>
+        </Transition>
+      </div>
     </div>
-  </div>
-  <div class="vdom-vapor-in-out">
-    <button @click="toggleVdom = !toggleVdom">
-      switch between vdom/vapor component in-out mode
-    </button>
-    <div>
-      <Transition name="fade" mode="in-out">
-        <VaporCompA v-if="toggleVdom" />
-        <VDomComp v-else></VDomComp>
-      </Transition>
+    <div class="in-out">
+      <button @click="toggleComponent">toggle in-out</button>
+      <div>
+        <Transition name="fade" mode="in-out">
+          <component :is="activeComponent"></component>
+        </Transition>
+      </div>
+    </div>
+    <div class="vdom">
+      <button @click="toggleVdom = !toggleVdom">toggle vdom component</button>
+      <div>
+        <Transition>
+          <VDomComp v-if="toggleVdom" />
+        </Transition>
+      </div>
+    </div>
+    <div class="vdom-vapor-out-in">
+      <button @click="toggleInteropComponent">
+        switch between vdom/vapor component out-in mode
+      </button>
+      <div>
+        <Transition name="fade" mode="out-in">
+          <component :is="interopComponent"></component>
+        </Transition>
+      </div>
+    </div>
+    <div class="vdom-vapor-in-out">
+      <button @click="toggleVdom = !toggleVdom">
+        switch between vdom/vapor component in-out mode
+      </button>
+      <div>
+        <Transition name="fade" mode="in-out">
+          <VaporCompA v-if="toggleVdom" />
+          <VDomComp v-else></VDomComp>
+        </Transition>
+      </div>
     </div>
   </div>
 </template>
@@ -106,3 +108,10 @@ function toggleInteropComponent() {
   height: 100px;
 }
 </style>
+<style>
+.transition-container > div {
+  padding: 15px;
+  border: 1px solid #f7f7f7;
+  margin-top: 15px;
+}
+</style>
diff --git a/packages-private/vapor-e2e-test/transition/components/VaporSlot.vue b/packages-private/vapor-e2e-test/transition/components/VaporSlot.vue
new file mode 100644 (file)
index 0000000..f5eff01
--- /dev/null
@@ -0,0 +1,8 @@
+<script setup vapor lang="ts">
+const msg = 'vapor'
+</script>
+<template>
+  <div>
+    <slot></slot>
+  </div>
+</template>
index 88bfe0ee7ea8631d53534ec7e67f48d0f305b72b..e77d51d1c039d85bcef8eb5f040b000a49a6254a 100644 (file)
@@ -1,5 +1,6 @@
 import { createVaporApp, vaporInteropPlugin } from 'vue'
 import App from './App.vue'
+import '../../../packages/vue/__tests__/e2e/style.css'
 import './style.css'
 
 createVaporApp(App).use(vaporInteropPlugin).mount('#app')
index 98f19c8cd92c5d31998f136d4a605ac3fed5f806..e6faf6cea535844cff3f9fc2dcee906978384c35 100644 (file)
 .fade-leave-to {
   opacity: 0;
 }
+
+.test-move,
+.test-enter-active,
+.test-leave-active {
+  transition: all 50ms cubic-bezier(0.55, 0, 0.1, 1);
+}
+
+.test-enter-from,
+.test-leave-to {
+  opacity: 0;
+  transform: scaleY(0.01) translate(30px, 0);
+}
+
+.test-leave-active {
+  position: absolute;
+}
index 846620ad017840e0772226335def34401a031a7f..f50fccea3cee448746785d46ced5fafcaa12925d 100644 (file)
@@ -15,6 +15,10 @@ export default defineConfig({
         interop: resolve(import.meta.dirname, 'interop/index.html'),
         todomvc: resolve(import.meta.dirname, 'todomvc/index.html'),
         transition: resolve(import.meta.dirname, 'transition/index.html'),
+        transitionGroup: resolve(
+          import.meta.dirname,
+          'transition-group/index.html',
+        ),
       },
     },
   },
index 35fd596ee83b5952589c2e9cd6fe03502e0a27c1..0ad4ef092fb9fcea99078702343c992aec6ba94c 100644 (file)
@@ -413,7 +413,9 @@ function dedupeProperties(results: DirectiveTransformResult[]): IRProp[] {
     }
     const name = prop.key.content
     const existing = knownProps.get(name)
-    if (existing) {
+    // prop names and event handler names can be the same but serve different purposes
+    // e.g. `:appear="true"` is a prop while `@appear="handler"` is an event handler
+    if (existing && existing.handler === prop.handler) {
       if (name === 'style' || name === 'class') {
         mergePropValues(existing, prop)
       }
index d390c69a21a5d433f4fd72ad32f2561799112b74..2d5ba72b39edeecc356cbcd38e91773742d4f1d9 100644 (file)
@@ -94,20 +94,35 @@ export function isInTransition(
   context: TransformContext<ElementNode>,
 ): boolean {
   const parentNode = context.parent && context.parent.node
-  return !!(parentNode && isTransitionNode(parentNode as ElementNode))
+  return !!(
+    parentNode &&
+    (isTransitionNode(parentNode as ElementNode) ||
+      isTransitionGroupNode(parentNode as ElementNode))
+  )
 }
 
 export function isTransitionNode(node: ElementNode): boolean {
   return node.type === NodeTypes.ELEMENT && isTransitionTag(node.tag)
 }
 
+export function isTransitionGroupNode(node: ElementNode): boolean {
+  return node.type === NodeTypes.ELEMENT && isTransitionGroupTag(node.tag)
+}
+
 export function isTransitionTag(tag: string): boolean {
   tag = tag.toLowerCase()
   return tag === 'transition' || tag === 'vaportransition'
 }
 
+export function isTransitionGroupTag(tag: string): boolean {
+  tag = tag.toLowerCase().replace(/-/g, '')
+  return tag === 'transitiongroup' || tag === 'vaportransitiongroup'
+}
+
 export function isBuiltInComponent(tag: string): string | undefined {
   if (isTransitionTag(tag)) {
     return 'VaporTransition'
+  } else if (isTransitionGroupTag(tag)) {
+    return 'VaporTransitionGroup'
   }
 }
index 8400e71d6c020200cb7bdf3c6c13ef8dcaea59d5..4f4993b5ce1850e05c7e657c7f82306b4ec60c86 100644 (file)
@@ -32,7 +32,7 @@ import { extend } from '@vue/shared'
 
 const positionMap = new WeakMap<VNode, DOMRect>()
 const newPositionMap = new WeakMap<VNode, DOMRect>()
-const moveCbKey = Symbol('_moveCb')
+export const moveCbKey: symbol = Symbol('_moveCb')
 const enterCbKey = Symbol('_enterCb')
 
 export type TransitionGroupProps = Omit<TransitionProps, 'mode'> & {
@@ -87,7 +87,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
 
       // we divide the work into three loops to avoid mixing DOM reads and writes
       // in each iteration - which helps prevent layout thrashing.
-      prevChildren.forEach(callPendingCbs)
+      prevChildren.forEach(vnode => callPendingCbs(vnode.el))
       prevChildren.forEach(recordPosition)
       const movedChildren = prevChildren.filter(applyTranslation)
 
@@ -96,20 +96,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
 
       movedChildren.forEach(c => {
         const el = c.el as ElementWithTransition
-        const style = el.style
-        addTransitionClass(el, moveClass)
-        style.transform = style.webkitTransform = style.transitionDuration = ''
-        const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
-          if (e && e.target !== el) {
-            return
-          }
-          if (!e || /transform$/.test(e.propertyName)) {
-            el.removeEventListener('transitionend', cb)
-            ;(el as any)[moveCbKey] = null
-            removeTransitionClass(el, moveClass)
-          }
-        })
-        el.addEventListener('transitionend', cb)
+        handleMovedChildren(el, moveClass)
       })
     })
 
@@ -177,8 +164,7 @@ export const TransitionGroup = TransitionGroupImpl as unknown as {
   }
 }
 
-function callPendingCbs(c: VNode) {
-  const el = c.el as any
+export function callPendingCbs(el: any): void {
   if (el[moveCbKey]) {
     el[moveCbKey]()
   }
@@ -192,19 +178,34 @@ function recordPosition(c: VNode) {
 }
 
 function applyTranslation(c: VNode): VNode | undefined {
-  const oldPos = positionMap.get(c)!
-  const newPos = newPositionMap.get(c)!
+  if (
+    baseApplyTranslation(
+      positionMap.get(c)!,
+      newPositionMap.get(c)!,
+      c.el as ElementWithTransition,
+    )
+  ) {
+    return c
+  }
+}
+
+export function baseApplyTranslation(
+  oldPos: DOMRect,
+  newPos: DOMRect,
+  el: ElementWithTransition,
+): boolean {
   const dx = oldPos.left - newPos.left
   const dy = oldPos.top - newPos.top
   if (dx || dy) {
-    const s = (c.el as HTMLElement).style
+    const s = (el as HTMLElement).style
     s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
     s.transitionDuration = '0s'
-    return c
+    return true
   }
+  return false
 }
 
-function hasCSSTransform(
+export function hasCSSTransform(
   el: ElementWithTransition,
   root: Node,
   moveClass: string,
@@ -231,3 +232,23 @@ function hasCSSTransform(
   container.removeChild(clone)
   return hasTransform
 }
+
+export const handleMovedChildren = (
+  el: ElementWithTransition,
+  moveClass: string,
+): void => {
+  const style = el.style
+  addTransitionClass(el, moveClass)
+  style.transform = style.webkitTransform = style.transitionDuration = ''
+  const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
+    if (e && e.target !== el) {
+      return
+    }
+    if (!e || /transform$/.test(e.propertyName)) {
+      el.removeEventListener('transitionend', cb)
+      ;(el as any)[moveCbKey] = null
+      removeTransitionClass(el, moveClass)
+    }
+  })
+  el.addEventListener('transitionend', cb)
+}
index 450ec74d15f80fa020e4229300874af3a88e6066..521bb46498a275b58d3c11f951b3712fed37d38d 100644 (file)
@@ -271,10 +271,22 @@ export { useCssModule } from './helpers/useCssModule'
 export { useCssVars } from './helpers/useCssVars'
 
 // DOM-only components
-export { Transition, type TransitionProps } from './components/Transition'
+export {
+  Transition,
+  type TransitionProps,
+  forceReflow,
+  addTransitionClass,
+  removeTransitionClass,
+} from './components/Transition'
+export type { ElementWithTransition } from './components/Transition'
 export {
   TransitionGroup,
   type TransitionGroupProps,
+  hasCSSTransform,
+  callPendingCbs,
+  moveCbKey,
+  handleMovedChildren,
+  baseApplyTranslation,
 } from './components/TransitionGroup'
 
 // **Internal** DOM-only runtime directive helpers
index 19653cd5daab80ec77ba3767435585ae20b9f5b3..1e4be0b5163083aa24bb24c884fa61cd177df23a 100644 (file)
@@ -22,6 +22,7 @@ import { currentInstance, isVaporComponent } from './component'
 import type { DynamicSlot } from './componentSlots'
 import { renderEffect } from './renderEffect'
 import { VaporVForFlags } from '../../shared/src/vaporFlags'
+import { applyTransitionEnterHooks } from './components/Transition'
 
 class ForBlock extends VaporFragment {
   scope: EffectScope | undefined
@@ -315,6 +316,10 @@ export const createFor = (
       getKey && getKey(item, key, index),
     ))
 
+    if (frag.$transition) {
+      applyTransitionEnterHooks(block.nodes, frag.$transition)
+    }
+
     if (parent) insert(block.nodes, parent, anchor)
 
     return block
@@ -415,8 +420,8 @@ function getItem(
   }
 }
 
-function normalizeAnchor(node: Block): Node {
-  if (node instanceof Node) {
+function normalizeAnchor(node: Block): Node | undefined {
+  if (node && node instanceof Node) {
     return node
   } else if (isArray(node)) {
     return normalizeAnchor(node[0])
@@ -439,3 +444,7 @@ export function getRestElement(val: any, keys: string[]): any {
 export function getDefaultValue(val: any, defaultVal: any): any {
   return val === undefined ? defaultVal : val
 }
+
+export function isForBlock(block: Block): block is ForBlock {
+  return block instanceof ForBlock
+}
index 6c904ab8690a5b6b0ef5fdff106e4e0cdef21021..26c0d8ca379fa8d91cacda490f8b185966eff1df 100644 (file)
@@ -28,6 +28,7 @@ export interface TransitionOptions {
 export interface VaporTransitionHooks extends TransitionHooks {
   state: TransitionState
   props: TransitionProps
+  disabledOnMoving?: boolean
 }
 
 export type TransitionBlock =
@@ -157,7 +158,11 @@ export function insert(
   if (block instanceof Node) {
     if (!isHydrating) {
       // don't apply transition on text or comment nodes
-      if ((block as TransitionBlock).$transition && block instanceof Element) {
+      if (
+        block instanceof Element &&
+        (block as TransitionBlock).$transition &&
+        !(block as TransitionBlock).$transition!.disabledOnMoving
+      ) {
         performTransitionEnter(
           block,
           (block as TransitionBlock).$transition as TransitionHooks,
index d262c472382336bd9e8f8e4c6f0d38d90fc42b41..fbba29f3ba94f60d54352d8f2669ec4c204a2cbb 100644 (file)
@@ -21,6 +21,7 @@ import {
   isFragment,
 } from '../block'
 import { type VaporComponentInstance, isVaporComponent } from '../component'
+import { isArray } from '@vue/shared'
 
 const decorate = (t: typeof VaporTransition) => {
   t.displayName = 'VaporTransition'
@@ -93,7 +94,7 @@ const getTransitionHooksContext = (
   return context
 }
 
-function resolveTransitionHooks(
+export function resolveTransitionHooks(
   block: TransitionBlock,
   props: TransitionProps,
   state: TransitionState,
@@ -118,10 +119,10 @@ function resolveTransitionHooks(
   return hooks
 }
 
-function setTransitionHooks(
+export function setTransitionHooks(
   block: TransitionBlock,
   hooks: VaporTransitionHooks,
-) {
+): void {
   block.$transition = hooks
 }
 
@@ -144,7 +145,7 @@ export function applyTransitionEnterHooks(
   setTransitionHooks(child, enterHooks)
   if (isFragment(block)) {
     // also set transition hooks on fragment for reusing during it's updating
-    setTransitionHooks(block, enterHooks)
+    setTransitionHooksToFragment(block, enterHooks)
   }
   return enterHooks
 }
@@ -211,7 +212,7 @@ export function findTransitionBlock(block: Block): TransitionBlock | undefined {
   } else if (isVaporComponent(block)) {
     child = findTransitionBlock(block.block)
     if (child && child.$key === undefined) child.$key = block.type.__name
-  } else if (Array.isArray(block)) {
+  } else if (isArray(block)) {
     child = block[0] as TransitionBlock
     let hasFound = false
     for (const c of block) {
@@ -254,3 +255,16 @@ export function setTransitionToInstance(
 
   setTransitionHooks(child, hooks)
 }
+
+export function setTransitionHooksToFragment(
+  block: Block,
+  hooks: VaporTransitionHooks,
+): void {
+  if (isFragment(block)) {
+    setTransitionHooks(block, hooks)
+  } else if (isArray(block)) {
+    for (let i = 0; i < block.length; i++) {
+      setTransitionHooksToFragment(block[i], hooks)
+    }
+  }
+}
diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts
new file mode 100644 (file)
index 0000000..35d39c6
--- /dev/null
@@ -0,0 +1,209 @@
+import {
+  type ElementWithTransition,
+  type TransitionGroupProps,
+  TransitionPropsValidators,
+  baseApplyTranslation,
+  callPendingCbs,
+  currentInstance,
+  forceReflow,
+  handleMovedChildren,
+  hasCSSTransform,
+  onBeforeUpdate,
+  onUpdated,
+  resolveTransitionProps,
+  useTransitionState,
+  warn,
+} from '@vue/runtime-dom'
+import { extend, isArray } from '@vue/shared'
+import {
+  type Block,
+  DynamicFragment,
+  type TransitionBlock,
+  type VaporTransitionHooks,
+  insert,
+  isFragment,
+} from '../block'
+import {
+  resolveTransitionHooks,
+  setTransitionHooks,
+  setTransitionHooksToFragment,
+} from './Transition'
+import { type ObjectVaporComponent, isVaporComponent } from '../component'
+import { isForBlock } from '../apiCreateFor'
+import { renderEffect, setDynamicProps } from '@vue/runtime-vapor'
+
+const positionMap = new WeakMap<TransitionBlock, DOMRect>()
+const newPositionMap = new WeakMap<TransitionBlock, DOMRect>()
+
+const decorate = (t: typeof VaporTransitionGroup) => {
+  delete (t.props! as any).mode
+  t.__vapor = true
+  return t
+}
+
+export const VaporTransitionGroup: ObjectVaporComponent = decorate({
+  name: 'VaporTransitionGroup',
+
+  props: /*@__PURE__*/ extend({}, TransitionPropsValidators, {
+    tag: String,
+    moveClass: String,
+  }),
+
+  setup(props: TransitionGroupProps, { slots }: any) {
+    const instance = currentInstance
+    const state = useTransitionState()
+    const cssTransitionProps = resolveTransitionProps(props)
+
+    let prevChildren: TransitionBlock[]
+    let children: TransitionBlock[]
+    let slottedBlock: Block
+
+    onBeforeUpdate(() => {
+      prevChildren = []
+      children = getTransitionBlocks(slottedBlock)
+      if (children) {
+        for (let i = 0; i < children.length; i++) {
+          const child = children[i]
+          if (isValidTransitionBlock(child)) {
+            prevChildren.push(child)
+            const hook = (child as TransitionBlock).$transition!
+            // disabled transition during moving, so the children will be
+            // inserted into the correct position immediately. this prevents
+            // `recordPosition` from getting incorrect positions in `onUpdated`
+            hook.disabledOnMoving = true
+            positionMap.set(child, getEl(child).getBoundingClientRect())
+          }
+        }
+      }
+    })
+
+    onUpdated(() => {
+      if (!prevChildren.length) {
+        return
+      }
+
+      const moveClass = props.moveClass || `${props.name || 'v'}-move`
+
+      const firstChild = findFirstChild(prevChildren)
+      if (
+        !firstChild ||
+        !hasCSSTransform(
+          firstChild as ElementWithTransition,
+          firstChild.parentNode as Node,
+          moveClass,
+        )
+      ) {
+        return
+      }
+
+      prevChildren.forEach(callPendingCbs)
+      prevChildren.forEach(child => {
+        delete child.$transition!.disabledOnMoving
+        recordPosition(child)
+      })
+      const movedChildren = prevChildren.filter(applyTranslation)
+
+      // force reflow to put everything in position
+      forceReflow()
+
+      movedChildren.forEach(c =>
+        handleMovedChildren(getEl(c) as ElementWithTransition, moveClass),
+      )
+    })
+
+    slottedBlock = slots.default && slots.default()
+
+    // store props and state on fragment for reusing during insert new items
+    setTransitionHooksToFragment(slottedBlock, {
+      props: cssTransitionProps,
+      state,
+    } as VaporTransitionHooks)
+
+    children = getTransitionBlocks(slottedBlock)
+    for (let i = 0; i < children.length; i++) {
+      const child = children[i]
+      if (isValidTransitionBlock(child)) {
+        if ((child as TransitionBlock).$key != null) {
+          setTransitionHooks(
+            child,
+            resolveTransitionHooks(child, cssTransitionProps, state, instance!),
+          )
+        } else if (__DEV__ && (child as TransitionBlock).$key == null) {
+          warn(`<transition-group> children must be keyed`)
+        }
+      }
+    }
+
+    const tag = props.tag
+    if (tag) {
+      const el = document.createElement(tag)
+      insert(slottedBlock, el)
+      // fallthrough attrs
+      renderEffect(() => setDynamicProps(el, [instance!.attrs]))
+      return [el]
+    } else {
+      const frag = __DEV__
+        ? new DynamicFragment('transitionGroup')
+        : new DynamicFragment()
+      renderEffect(() => frag.update(() => slottedBlock))
+      return frag
+    }
+  },
+})
+
+function getTransitionBlocks(block: Block) {
+  let children: TransitionBlock[] = []
+  if (block instanceof Node) {
+    children.push(block)
+  } else if (isVaporComponent(block)) {
+    children.push(...getTransitionBlocks(block.block))
+  } else if (isArray(block)) {
+    for (let i = 0; i < block.length; i++) {
+      const b = block[i]
+      const blocks = getTransitionBlocks(b)
+      if (isForBlock(b)) blocks.forEach(block => (block.$key = b.key))
+      children.push(...blocks)
+    }
+  } else if (isFragment(block)) {
+    if (block.insert) {
+      // vdom component
+      children.push(block)
+    } else {
+      children.push(...getTransitionBlocks(block.nodes))
+    }
+  }
+
+  return children
+}
+
+function isValidTransitionBlock(block: Block): boolean {
+  return !!(block instanceof Element || (isFragment(block) && block.insert))
+}
+
+function getEl(c: TransitionBlock): Element {
+  return (isFragment(c) ? c.nodes : c) as Element
+}
+
+function recordPosition(c: TransitionBlock) {
+  newPositionMap.set(c, getEl(c).getBoundingClientRect())
+}
+
+function applyTranslation(c: TransitionBlock): TransitionBlock | undefined {
+  if (
+    baseApplyTranslation(
+      positionMap.get(c)!,
+      newPositionMap.get(c)!,
+      getEl(c) as ElementWithTransition,
+    )
+  ) {
+    return c
+  }
+}
+
+function findFirstChild(children: TransitionBlock[]): Element | undefined {
+  for (let i = 0; i < children.length; i++) {
+    const child = children[i]
+    const el = getEl(child)
+    if (el.isConnected) return el
+  }
+}
index 1a23a97a3c600781a388c058adb8320354c7f204..df7810404b58b66653b6b7d87ec110bb2e6e4f67 100644 (file)
@@ -43,3 +43,4 @@ export {
 } from './directives/vModel'
 export { withVaporDirectives } from './directives/custom'
 export { VaporTransition } from './components/Transition'
+export { VaporTransitionGroup } from './components/TransitionGroup'
index 0b17b4729221cc3a6d7d8704a190435361205076..b9bce9d6f8a378bbef14c93467414db07ee16bbd 100644 (file)
@@ -35,7 +35,7 @@ import {
   insert,
   remove,
 } from './block'
-import { EMPTY_OBJ, extend, isFunction } from '@vue/shared'
+import { EMPTY_OBJ, extend, isFunction, isReservedProp } from '@vue/shared'
 import { type RawProps, rawPropsProxyHandlers } from './componentProps'
 import type { RawSlots, VaporSlot } from './componentSlots'
 import { renderEffect } from './renderEffect'
@@ -54,7 +54,15 @@ const vaporInteropImpl: Omit<
     const prev = currentInstance
     simpleSetCurrentInstance(parentComponent)
 
-    const propsRef = shallowRef(vnode.props)
+    // filter out reserved props
+    const props: VNode['props'] = {}
+    for (const key in vnode.props) {
+      if (!isReservedProp(key)) {
+        props[key] = vnode.props[key]
+      }
+    }
+
+    const propsRef = shallowRef(props)
     const slotsRef = shallowRef(vnode.children)
 
     // @ts-expect-error
@@ -221,6 +229,7 @@ function createVDOMComponent(
         parentInstance as any,
       )
     }
+    frag.nodes = vnode.el as Node
     simpleSetCurrentInstance(prev)
   }
 
diff --git a/packages/vue/__tests__/e2e/style.css b/packages/vue/__tests__/e2e/style.css
new file mode 100644 (file)
index 0000000..ae6749b
--- /dev/null
@@ -0,0 +1,77 @@
+.test {
+  -webkit-transition: opacity 50ms ease;
+  transition: opacity 50ms ease;
+}
+.group-move {
+  -webkit-transition: -webkit-transform 50ms ease;
+  transition: transform 50ms ease;
+}
+.v-appear,
+.v-enter,
+.v-leave-active,
+.test-appear,
+.test-enter,
+.test-leave-active,
+.test-reflow-enter,
+.test-reflow-leave-to,
+.hello,
+.bye.active,
+.changed-enter {
+  opacity: 0;
+}
+.test-reflow-leave-active,
+.test-reflow-enter-active {
+  -webkit-transition: opacity 50ms ease;
+  transition: opacity 50ms ease;
+}
+.test-reflow-leave-from {
+  opacity: 0.9;
+}
+.test-anim-enter-active {
+  animation: test-enter 50ms;
+  -webkit-animation: test-enter 50ms;
+}
+.test-anim-leave-active {
+  animation: test-leave 50ms;
+  -webkit-animation: test-leave 50ms;
+}
+.test-anim-long-enter-active {
+  animation: test-enter 100ms;
+  -webkit-animation: test-enter 100ms;
+}
+.test-anim-long-leave-active {
+  animation: test-leave 100ms;
+  -webkit-animation: test-leave 100ms;
+}
+@keyframes test-enter {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+@-webkit-keyframes test-enter {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+@keyframes test-leave {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
+@-webkit-keyframes test-leave {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
index ab404d67dc7286fbc05e20c7bf47c9a5bd5529da..7f5fce9e34a8a90e979b707551d548243bc1489a 100644 (file)
@@ -1,82 +1,4 @@
 <script src="../../dist/vue.global.js"></script>
 
 <div id="app"></div>
-<style>
-  .test {
-    -webkit-transition: opacity 50ms ease;
-    transition: opacity 50ms ease;
-  }
-  .group-move {
-    -webkit-transition: -webkit-transform 50ms ease;
-    transition: transform 50ms ease;
-  }
-  .v-appear,
-  .v-enter,
-  .v-leave-active,
-  .test-appear,
-  .test-enter,
-  .test-leave-active,
-  .test-reflow-enter,
-  .test-reflow-leave-to,
-  .hello,
-  .bye.active,
-  .changed-enter {
-    opacity: 0;
-  }
-  .test-reflow-leave-active,
-  .test-reflow-enter-active {
-    -webkit-transition: opacity 50ms ease;
-    transition: opacity 50ms ease;
-  }
-  .test-reflow-leave-from {
-    opacity: 0.9;
-  }
-  .test-anim-enter-active {
-    animation: test-enter 50ms;
-    -webkit-animation: test-enter 50ms;
-  }
-  .test-anim-leave-active {
-    animation: test-leave 50ms;
-    -webkit-animation: test-leave 50ms;
-  }
-  .test-anim-long-enter-active {
-    animation: test-enter 100ms;
-    -webkit-animation: test-enter 100ms;
-  }
-  .test-anim-long-leave-active {
-    animation: test-leave 100ms;
-    -webkit-animation: test-leave 100ms;
-  }
-  @keyframes test-enter {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-  @-webkit-keyframes test-enter {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-  @keyframes test-leave {
-    from {
-      opacity: 1;
-    }
-    to {
-      opacity: 0;
-    }
-  }
-  @-webkit-keyframes test-leave {
-    from {
-      opacity: 1;
-    }
-    to {
-      opacity: 0;
-    }
-  }
-</style>
+<link rel="stylesheet" href="style.css" />