Repository: vuejs/vue
Pull request: #4371 - Explicit transition durations

Add a new `duration` prop on `<transition>` component (see #4371):

```html
<!-- Both enter and leave explicit total duration -->
<transition name="complex-animation" duration="500">
<transition name="complex-animation" duration=".5s">
<transition name="complex-animation" duration="500ms">
<!-- Specific enter and leave explicit total durations -->
<transition name="complex-animation" :duration="{ enter: 1000, leave: '500ms' }">
```

Changed files:
filename: src/platforms/web/runtime/components/transition.js
patch: @@ -21,7 +21,8 @@ export const transitionProps = {
   leaveActiveClass: String,
   appearClass: String,
   appearActiveClass: String,
-  appearToClass: String
+  appearToClass: String,
+  duration: [Number, Object]
 }
 
 // in case the child is also an abstract component, e.g. <keep-alive>

filename: src/platforms/web/runtime/modules/transition.js
patch: @@ -1,15 +1,15 @@
 /* @flow */
 
-import { inBrowser, isIE9 } from 'core/util/index'
-import { once } from 'shared/util'
+import { once, isObject } from 'shared/util'
+import { inBrowser, isIE9, warn } from 'core/util/index'
 import { mergeVNodeHook } from 'core/vdom/helpers/index'
 import { activeInstance } from 'core/instance/lifecycle'
 import {
-  resolveTransition,
   nextFrame,
+  resolveTransition,
+  whenTransitionEnds,
   addTransitionClass,
-  removeTransitionClass,
-  whenTransitionEnds
+  removeTransitionClass
 } from '../transition-util'
 
 export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
@@ -47,7 +47,8 @@ export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
     beforeAppear,
     appear,
     afterAppear,
-    appearCancelled
+    appearCancelled,
+    duration
   } = data
 
   // activeInstance will always be the <transition> component managing this
@@ -70,11 +71,17 @@ export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
   const startClass = isAppear ? appearClass : enterClass
   const activeClass = isAppear ? appearActiveClass : enterActiveClass
   const toClass = isAppear ? appearToClass : enterToClass
+
   const beforeEnterHook = isAppear ? (beforeAppear || beforeEnter) : beforeEnter
   const enterHook = isAppear ? (typeof appear === 'function' ? appear : enter) : enter
   const afterEnterHook = isAppear ? (afterAppear || afterEnter) : afterEnter
   const enterCancelledHook = isAppear ? (appearCancelled || enterCancelled) : enterCancelled
 
+  const explicitEnterDuration = isObject(duration) ? duration.enter : duration
+  if (process.env.NODE_ENV !== 'production' && explicitEnterDuration != null) {
+    checkDuration(explicitEnterDuration, 'enter', vnode)
+  }
+
   const expectsCSS = css !== false && !isIE9
   const userWantsControl =
     enterHook &&
@@ -121,7 +128,11 @@ export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
       addTransitionClass(el, toClass)
       removeTransitionClass(el, startClass)
       if (!cb.cancelled && !userWantsControl) {
-        whenTransitionEnds(el, type, cb)
+        if (isValidDuration(explicitEnterDuration)) {
+          setTimeout(cb, explicitEnterDuration)
+        } else {
+          whenTransitionEnds(el, type, cb)
+        }
       }
     })
   }
@@ -165,7 +176,8 @@ export function leave (vnode: VNodeWithData, rm: Function) {
     leave,
     afterLeave,
     leaveCancelled,
-    delayLeave
+    delayLeave,
+    duration
   } = data
 
   const expectsCSS = css !== false && !isIE9
@@ -175,6 +187,11 @@ export function leave (vnode: VNodeWithData, rm: Function) {
     // the length of original fn as _length
     (leave._length || leave.length) > 1
 
+  const explicitLeaveDuration = isObject(duration) ? duration.leave : duration
+  if (process.env.NODE_ENV !== 'production' && explicitLeaveDuration != null) {
+    checkDuration(explicitLeaveDuration, 'leave', vnode)
+  }
+
   const cb = el._leaveCb = once(() => {
     if (el.parentNode && el.parentNode._pending) {
       el.parentNode._pending[vnode.key] = null
@@ -218,7 +235,11 @@ export function leave (vnode: VNodeWithData, rm: Function) {
         addTransitionClass(el, leaveToClass)
         removeTransitionClass(el, leaveClass)
         if (!cb.cancelled && !userWantsControl) {
-          whenTransitionEnds(el, type, cb)
+          if (isValidDuration(explicitLeaveDuration)) {
+            setTimeout(cb, explicitLeaveDuration)
+          } else {
+            whenTransitionEnds(el, type, cb)
+          }
         }
       })
     }
@@ -229,6 +250,27 @@ export function leave (vnode: VNodeWithData, rm: Function) {
   }
 }
 
+// only used in dev mode
+function checkDuration (val, name, vnode) {
+  if (typeof val !== 'number') {
+    warn(
+      `<transition> explicit ${name} duration is not a valid number - ` +
+      `got ${JSON.stringify(val)}.`,
+      vnode.context
+    )
+  } else if (isNaN(val)) {
+    warn(
+      `<transition> explicit ${name} duration is NaN - ` +
+      'the duration expression might be incorrect.',
+      vnode.context
+    )
+  }
+}
+
+function isValidDuration (val) {
+  return typeof val === 'number' && !isNaN(val)
+}
+
 function _enter (_: any, vnode: VNodeWithData) {
   if (!vnode.data.show) {
     enter(vnode)

filename: src/platforms/web/runtime/transition-util.js
patch: @@ -1,8 +1,8 @@
 /* @flow */
 
 import { inBrowser, isIE9 } from 'core/util/index'
-import { remove, extend, cached } from 'shared/util'
 import { addClass, removeClass } from './class-util'
+import { remove, extend, cached } from 'shared/util'
 
 export function resolveTransition (def?: string | Object): ?Object {
   if (!def) {

filename: test/unit/features/transition/transition.spec.js
patch: @@ -6,6 +6,7 @@ import { nextFrame } from 'web/runtime/transition-util'
 if (!isIE9) {
   describe('Transition basic', () => {
     const { duration, buffer } = injectStyles()
+    const explicitDuration = 100
 
     let el
     beforeEach(() => {
@@ -875,5 +876,232 @@ if (!isIE9) {
       }).$mount()
       expect(`<transition> can only be used on a single element`).toHaveBeenWarned()
     })
+
+    it('explicit transition total duration', done => {
+      const vm = new Vue({
+        template: `
+          <div>
+            <transition :duration="${explicitDuration}">
+              <div v-if="ok" class="test">foo</div>
+            </transition>
+          </div>
+        `,
+        data: { ok: true }
+      }).$mount(el)
+
+      vm.ok = false
+
+      waitForUpdate(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave v-leave-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(explicitDuration - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children.length).toBe(0)
+        vm.ok = true
+      }).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter v-enter-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(explicitDuration - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children[0].className).toBe('test')
+      }).then(done)
+    })
+
+    it('explicit transition enter duration and auto leave duration', done => {
+      const vm = new Vue({
+        template: `
+          <div>
+            <transition :duration="{ enter: ${explicitDuration} }">
+              <div v-if="ok" class="test">foo</div>
+            </transition>
+          </div>
+        `,
+        data: { ok: true }
+      }).$mount(el)
+
+      vm.ok = false
+
+      waitForUpdate(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave v-leave-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(duration - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children.length).toBe(0)
+        vm.ok = true
+      }).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter v-enter-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(explicitDuration - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children[0].className).toBe('test')
+      }).then(done)
+    })
+
+    it('explicit transition leave duration and auto enter duration', done => {
+      const vm = new Vue({
+        template: `
+          <div>
+            <transition :duration="{ leave: ${explicitDuration} }">
+              <div v-if="ok" class="test">foo</div>
+            </transition>
+          </div>
+        `,
+        data: { ok: true }
+      }).$mount(el)
+
+      vm.ok = false
+
+      waitForUpdate(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave v-leave-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(explicitDuration - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children.length).toBe(0)
+        vm.ok = true
+      }).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter v-enter-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(duration - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children[0].className).toBe('test')
+      }).then(done)
+    })
+
+    it('explicit transition separate enter and leave duration', done => {
+      const enter = 100
+      const leave = 200
+
+      const vm = new Vue({
+        template: `
+          <div>
+            <transition :duration="{ enter: ${enter}, leave: ${leave} }">
+              <div v-if="ok" class="test">foo</div>
+            </transition>
+          </div>
+        `,
+        data: { ok: true }
+      }).$mount(el)
+
+      vm.ok = false
+
+      waitForUpdate(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave v-leave-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(leave - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children.length).toBe(0)
+        vm.ok = true
+      }).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter v-enter-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(enter - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children[0].className).toBe('test')
+      }).then(done)
+    })
+
+    it('explicit transition enter and leave duration + duration change', done => {
+      const enter1 = 200
+      const enter2 = 100
+      const leave1 = 50
+      const leave2 = 300
+
+      const vm = new Vue({
+        template: `
+          <div>
+            <transition :duration="{ enter: enter, leave: leave }">
+              <div v-if="ok" class="test">foo</div>
+            </transition>
+          </div>
+        `,
+        data: {
+          ok: true,
+          enter: enter1,
+          leave: leave1
+        }
+      }).$mount(el)
+
+      vm.ok = false
+
+      waitForUpdate(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave v-leave-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(leave1 - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children.length).toBe(0)
+        vm.ok = true
+      }).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter v-enter-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(enter1 - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children[0].className).toBe('test')
+        vm.enter = enter2
+        vm.leave = leave2
+      }).then(() => {
+        vm.ok = false
+      }).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave v-leave-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(leave2 - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-leave-active v-leave-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children.length).toBe(0)
+        vm.ok = true
+      }).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter v-enter-active')
+      }).thenWaitFor(nextFrame).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(enter2 - buffer).then(() => {
+        expect(vm.$el.children[0].className).toBe('test v-enter-active v-enter-to')
+      }).thenWaitFor(buffer * 2).then(() => {
+        expect(vm.$el.children[0].className).toBe('test')
+      }).then(done)
+    })
+
+    it('warn invalid explicit durations', done => {
+      const vm = new Vue({
+        template: `
+          <div>
+            <transition :duration="{ enter: NaN, leave: 'foo' }">
+              <div v-if="ok" class="test">foo</div>
+            </transition>
+          </div>
+        `,
+        data: {
+          ok: true
+        }
+      }).$mount(el)
+
+      vm.ok = false
+      waitForUpdate(() => {
+        expect(`<transition> explicit leave duration is not a valid number - got "foo"`).toHaveBeenWarned()
+      }).thenWaitFor(duration + buffer).then(() => {
+        vm.ok = true
+      }).then(() => {
+        expect(`<transition> explicit enter duration is NaN`).toHaveBeenWarned()
+      }).then(done)
+    })
   })
 }