330 lines
9.0 KiB
TypeScript
330 lines
9.0 KiB
TypeScript
import {
|
|
BaseTransition,
|
|
BaseTransitionProps,
|
|
h,
|
|
warn,
|
|
FunctionalComponent,
|
|
getCurrentInstance,
|
|
callWithAsyncErrorHandling
|
|
} from '@vue/runtime-core'
|
|
import { isObject } from '@vue/shared'
|
|
import { ErrorCodes } from 'packages/runtime-core/src/errorHandling'
|
|
|
|
const TRANSITION = 'transition'
|
|
const ANIMATION = 'animation'
|
|
|
|
export interface TransitionProps extends BaseTransitionProps {
|
|
name?: string
|
|
type?: typeof TRANSITION | typeof ANIMATION
|
|
css?: boolean
|
|
duration?: number | { enter: number; leave: number }
|
|
// custom transition classes
|
|
enterFromClass?: string
|
|
enterActiveClass?: string
|
|
enterToClass?: string
|
|
appearFromClass?: string
|
|
appearActiveClass?: string
|
|
appearToClass?: string
|
|
leaveFromClass?: string
|
|
leaveActiveClass?: string
|
|
leaveToClass?: string
|
|
}
|
|
|
|
// DOM Transition is a higher-order-component based on the platform-agnostic
|
|
// base Transition component, with DOM-specific logic.
|
|
export const Transition: FunctionalComponent<TransitionProps> = (
|
|
props,
|
|
{ slots }
|
|
) => h(BaseTransition, resolveTransitionProps(props), slots)
|
|
|
|
export const TransitionPropsValidators = {
|
|
...(BaseTransition as any).props,
|
|
name: String,
|
|
type: String,
|
|
css: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
duration: Object,
|
|
enterFromClass: String,
|
|
enterActiveClass: String,
|
|
enterToClass: String,
|
|
appearFromClass: String,
|
|
appearActiveClass: String,
|
|
appearToClass: String,
|
|
leaveFromClass: String,
|
|
leaveActiveClass: String,
|
|
leaveToClass: String
|
|
}
|
|
|
|
if (__DEV__) {
|
|
Transition.props = TransitionPropsValidators
|
|
}
|
|
|
|
export function resolveTransitionProps({
|
|
name = 'v',
|
|
type,
|
|
css = true,
|
|
duration,
|
|
enterFromClass = `${name}-enter-from`,
|
|
enterActiveClass = `${name}-enter-active`,
|
|
enterToClass = `${name}-enter-to`,
|
|
appearFromClass = enterFromClass,
|
|
appearActiveClass = enterActiveClass,
|
|
appearToClass = enterToClass,
|
|
leaveFromClass = `${name}-leave-from`,
|
|
leaveActiveClass = `${name}-leave-active`,
|
|
leaveToClass = `${name}-leave-to`,
|
|
...baseProps
|
|
}: TransitionProps): BaseTransitionProps {
|
|
if (!css) {
|
|
return baseProps
|
|
}
|
|
|
|
const instance = getCurrentInstance()!
|
|
const durations = normalizeDuration(duration)
|
|
const enterDuration = durations && durations[0]
|
|
const leaveDuration = durations && durations[1]
|
|
const { appear, onBeforeEnter, onEnter, onLeave } = baseProps
|
|
|
|
// is appearing
|
|
if (appear && !getCurrentInstance()!.isMounted) {
|
|
enterFromClass = appearFromClass
|
|
enterActiveClass = appearActiveClass
|
|
enterToClass = appearToClass
|
|
}
|
|
|
|
type Hook = (el: HTMLElement, done?: () => void) => void
|
|
|
|
const finishEnter: Hook = (el, done) => {
|
|
removeTransitionClass(el, enterToClass)
|
|
removeTransitionClass(el, enterActiveClass)
|
|
done && done()
|
|
}
|
|
|
|
const finishLeave: Hook = (el, done) => {
|
|
removeTransitionClass(el, leaveToClass)
|
|
removeTransitionClass(el, leaveActiveClass)
|
|
done && done()
|
|
}
|
|
|
|
// only needed for user hooks called in nextFrame
|
|
// sync errors are already handled by BaseTransition
|
|
function callHookWithErrorHandling(hook: Hook, args: any[]) {
|
|
callWithAsyncErrorHandling(hook, instance, ErrorCodes.TRANSITION_HOOK, args)
|
|
}
|
|
|
|
return {
|
|
...baseProps,
|
|
onBeforeEnter(el) {
|
|
onBeforeEnter && onBeforeEnter(el)
|
|
addTransitionClass(el, enterActiveClass)
|
|
addTransitionClass(el, enterFromClass)
|
|
},
|
|
onEnter(el, done) {
|
|
nextFrame(() => {
|
|
const resolve = () => finishEnter(el, done)
|
|
onEnter && callHookWithErrorHandling(onEnter, [el, resolve])
|
|
removeTransitionClass(el, enterFromClass)
|
|
addTransitionClass(el, enterToClass)
|
|
if (!(onEnter && onEnter.length > 1)) {
|
|
if (enterDuration) {
|
|
setTimeout(resolve, enterDuration)
|
|
} else {
|
|
whenTransitionEnds(el, type, resolve)
|
|
}
|
|
}
|
|
})
|
|
},
|
|
onLeave(el, done) {
|
|
addTransitionClass(el, leaveActiveClass)
|
|
addTransitionClass(el, leaveFromClass)
|
|
nextFrame(() => {
|
|
const resolve = () => finishLeave(el, done)
|
|
onLeave && callHookWithErrorHandling(onLeave, [el, resolve])
|
|
removeTransitionClass(el, leaveFromClass)
|
|
addTransitionClass(el, leaveToClass)
|
|
if (!(onLeave && onLeave.length > 1)) {
|
|
if (leaveDuration) {
|
|
setTimeout(resolve, leaveDuration)
|
|
} else {
|
|
whenTransitionEnds(el, type, resolve)
|
|
}
|
|
}
|
|
})
|
|
},
|
|
onEnterCancelled: finishEnter,
|
|
onLeaveCancelled: finishLeave
|
|
}
|
|
}
|
|
|
|
function normalizeDuration(
|
|
duration: TransitionProps['duration']
|
|
): [number, number] | null {
|
|
if (duration == null) {
|
|
return null
|
|
} else if (isObject(duration)) {
|
|
return [toNumber(duration.enter), toNumber(duration.leave)]
|
|
} else {
|
|
const n = toNumber(duration)
|
|
return [n, n]
|
|
}
|
|
}
|
|
|
|
function toNumber(val: unknown): number {
|
|
const res = Number(val || 0)
|
|
if (__DEV__) validateDuration(res)
|
|
return res
|
|
}
|
|
|
|
function validateDuration(val: unknown) {
|
|
if (typeof val !== 'number') {
|
|
warn(
|
|
`<transition> explicit duration is not a valid number - ` +
|
|
`got ${JSON.stringify(val)}.`
|
|
)
|
|
} else if (isNaN(val)) {
|
|
warn(
|
|
`<transition> explicit duration is NaN - ` +
|
|
'the duration expression might be incorrect.'
|
|
)
|
|
}
|
|
}
|
|
|
|
export interface ElementWithTransition extends HTMLElement {
|
|
// _vtc = Vue Transition Classes.
|
|
// Store the temporarily-added transition classes on the element
|
|
// so that we can avoid overwriting them if the element's class is patched
|
|
// during the transition.
|
|
_vtc?: Set<string>
|
|
}
|
|
|
|
export function addTransitionClass(el: ElementWithTransition, cls: string) {
|
|
cls.split(/\s+/).forEach(c => c && el.classList.add(c))
|
|
;(el._vtc || (el._vtc = new Set())).add(cls)
|
|
}
|
|
|
|
export function removeTransitionClass(el: ElementWithTransition, cls: string) {
|
|
cls.split(/\s+/).forEach(c => c && el.classList.remove(c))
|
|
if (el._vtc) {
|
|
el._vtc.delete(cls)
|
|
if (!el._vtc!.size) {
|
|
el._vtc = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
function nextFrame(cb: () => void) {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(cb)
|
|
})
|
|
}
|
|
|
|
function whenTransitionEnds(
|
|
el: Element,
|
|
expectedType: TransitionProps['type'] | undefined,
|
|
cb: () => void
|
|
) {
|
|
const { type, timeout, propCount } = getTransitionInfo(el, expectedType)
|
|
if (!type) {
|
|
return cb()
|
|
}
|
|
|
|
const endEvent = type + 'end'
|
|
let ended = 0
|
|
const end = () => {
|
|
el.removeEventListener(endEvent, onEnd)
|
|
cb()
|
|
}
|
|
const onEnd = (e: Event) => {
|
|
if (e.target === el) {
|
|
if (++ended >= propCount) {
|
|
end()
|
|
}
|
|
}
|
|
}
|
|
setTimeout(() => {
|
|
if (ended < propCount) {
|
|
end()
|
|
}
|
|
}, timeout + 1)
|
|
el.addEventListener(endEvent, onEnd)
|
|
}
|
|
|
|
interface CSSTransitionInfo {
|
|
type: typeof TRANSITION | typeof ANIMATION | null
|
|
propCount: number
|
|
timeout: number
|
|
hasTransform: boolean
|
|
}
|
|
|
|
export function getTransitionInfo(
|
|
el: Element,
|
|
expectedType?: TransitionProps['type']
|
|
): CSSTransitionInfo {
|
|
const styles: any = window.getComputedStyle(el)
|
|
// JSDOM may return undefined for transition properties
|
|
const getStyleProperties = (key: string) => (styles[key] || '').split(', ')
|
|
const transitionDelays = getStyleProperties(TRANSITION + 'Delay')
|
|
const transitionDurations = getStyleProperties(TRANSITION + 'Duration')
|
|
const transitionTimeout = getTimeout(transitionDelays, transitionDurations)
|
|
const animationDelays = getStyleProperties(ANIMATION + 'Delay')
|
|
const animationDurations = getStyleProperties(ANIMATION + 'Duration')
|
|
const animationTimeout = getTimeout(animationDelays, animationDurations)
|
|
|
|
let type: CSSTransitionInfo['type'] = null
|
|
let timeout = 0
|
|
let propCount = 0
|
|
/* istanbul ignore if */
|
|
if (expectedType === TRANSITION) {
|
|
if (transitionTimeout > 0) {
|
|
type = TRANSITION
|
|
timeout = transitionTimeout
|
|
propCount = transitionDurations.length
|
|
}
|
|
} else if (expectedType === ANIMATION) {
|
|
if (animationTimeout > 0) {
|
|
type = ANIMATION
|
|
timeout = animationTimeout
|
|
propCount = animationDurations.length
|
|
}
|
|
} else {
|
|
timeout = Math.max(transitionTimeout, animationTimeout)
|
|
type =
|
|
timeout > 0
|
|
? transitionTimeout > animationTimeout
|
|
? TRANSITION
|
|
: ANIMATION
|
|
: null
|
|
propCount = type
|
|
? type === TRANSITION
|
|
? transitionDurations.length
|
|
: animationDurations.length
|
|
: 0
|
|
}
|
|
const hasTransform =
|
|
type === TRANSITION &&
|
|
/\b(transform|all)(,|$)/.test(styles[TRANSITION + 'Property'])
|
|
return {
|
|
type,
|
|
timeout,
|
|
propCount,
|
|
hasTransform
|
|
}
|
|
}
|
|
|
|
function getTimeout(delays: string[], durations: string[]): number {
|
|
while (delays.length < durations.length) {
|
|
delays = delays.concat(delays)
|
|
}
|
|
return Math.max(...durations.map((d, i) => toMs(d) + toMs(delays[i])))
|
|
}
|
|
|
|
// Old versions of Chromium (below 61.0.3163.100) formats floating pointer
|
|
// numbers in a locale-dependent way, using a comma instead of a dot.
|
|
// If comma is not replaced with a dot, the input will be rounded down
|
|
// (i.e. acting as a floor function) causing unexpected behaviors
|
|
function toMs(s: string): number {
|
|
return Number(s.slice(0, -1).replace(',', '.')) * 1000
|
|
}
|