Transition.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import {
  2. BaseTransition,
  3. BaseTransitionProps,
  4. BaseTransitionPropsValidators,
  5. h,
  6. assertNumber,
  7. FunctionalComponent,
  8. compatUtils,
  9. DeprecationTypes
  10. } from '@vue/runtime-core'
  11. import { isObject, toNumber, extend, isArray } from '@vue/shared'
  12. const TRANSITION = 'transition'
  13. const ANIMATION = 'animation'
  14. type AnimationTypes = typeof TRANSITION | typeof ANIMATION
  15. export interface TransitionProps extends BaseTransitionProps<Element> {
  16. name?: string
  17. type?: AnimationTypes
  18. css?: boolean
  19. duration?: number | { enter: number; leave: number }
  20. // custom transition classes
  21. enterFromClass?: string
  22. enterActiveClass?: string
  23. enterToClass?: string
  24. appearFromClass?: string
  25. appearActiveClass?: string
  26. appearToClass?: string
  27. leaveFromClass?: string
  28. leaveActiveClass?: string
  29. leaveToClass?: string
  30. }
  31. export const vtcKey = Symbol('_vtc')
  32. export interface ElementWithTransition extends HTMLElement {
  33. // _vtc = Vue Transition Classes.
  34. // Store the temporarily-added transition classes on the element
  35. // so that we can avoid overwriting them if the element's class is patched
  36. // during the transition.
  37. [vtcKey]?: Set<string>
  38. }
  39. // DOM Transition is a higher-order-component based on the platform-agnostic
  40. // base Transition component, with DOM-specific logic.
  41. export const Transition: FunctionalComponent<TransitionProps> = (
  42. props,
  43. { slots }
  44. ) => h(BaseTransition, resolveTransitionProps(props), slots)
  45. Transition.displayName = 'Transition'
  46. if (__COMPAT__) {
  47. Transition.__isBuiltIn = true
  48. }
  49. const DOMTransitionPropsValidators = {
  50. name: String,
  51. type: String,
  52. css: {
  53. type: Boolean,
  54. default: true
  55. },
  56. duration: [String, Number, Object],
  57. enterFromClass: String,
  58. enterActiveClass: String,
  59. enterToClass: String,
  60. appearFromClass: String,
  61. appearActiveClass: String,
  62. appearToClass: String,
  63. leaveFromClass: String,
  64. leaveActiveClass: String,
  65. leaveToClass: String
  66. }
  67. export const TransitionPropsValidators = (Transition.props =
  68. /*#__PURE__*/ extend(
  69. {},
  70. BaseTransitionPropsValidators as any,
  71. DOMTransitionPropsValidators
  72. ))
  73. /**
  74. * #3227 Incoming hooks may be merged into arrays when wrapping Transition
  75. * with custom HOCs.
  76. */
  77. const callHook = (
  78. hook: Function | Function[] | undefined,
  79. args: any[] = []
  80. ) => {
  81. if (isArray(hook)) {
  82. hook.forEach(h => h(...args))
  83. } else if (hook) {
  84. hook(...args)
  85. }
  86. }
  87. /**
  88. * Check if a hook expects a callback (2nd arg), which means the user
  89. * intends to explicitly control the end of the transition.
  90. */
  91. const hasExplicitCallback = (
  92. hook: Function | Function[] | undefined
  93. ): boolean => {
  94. return hook
  95. ? isArray(hook)
  96. ? hook.some(h => h.length > 1)
  97. : hook.length > 1
  98. : false
  99. }
  100. export function resolveTransitionProps(
  101. rawProps: TransitionProps
  102. ): BaseTransitionProps<Element> {
  103. const baseProps: BaseTransitionProps<Element> = {}
  104. for (const key in rawProps) {
  105. if (!(key in DOMTransitionPropsValidators)) {
  106. ;(baseProps as any)[key] = (rawProps as any)[key]
  107. }
  108. }
  109. if (rawProps.css === false) {
  110. return baseProps
  111. }
  112. const {
  113. name = 'v',
  114. type,
  115. duration,
  116. enterFromClass = `${name}-enter-from`,
  117. enterActiveClass = `${name}-enter-active`,
  118. enterToClass = `${name}-enter-to`,
  119. appearFromClass = enterFromClass,
  120. appearActiveClass = enterActiveClass,
  121. appearToClass = enterToClass,
  122. leaveFromClass = `${name}-leave-from`,
  123. leaveActiveClass = `${name}-leave-active`,
  124. leaveToClass = `${name}-leave-to`
  125. } = rawProps
  126. // legacy transition class compat
  127. const legacyClassEnabled =
  128. __COMPAT__ &&
  129. compatUtils.isCompatEnabled(DeprecationTypes.TRANSITION_CLASSES, null)
  130. let legacyEnterFromClass: string
  131. let legacyAppearFromClass: string
  132. let legacyLeaveFromClass: string
  133. if (__COMPAT__ && legacyClassEnabled) {
  134. const toLegacyClass = (cls: string) => cls.replace(/-from$/, '')
  135. if (!rawProps.enterFromClass) {
  136. legacyEnterFromClass = toLegacyClass(enterFromClass)
  137. }
  138. if (!rawProps.appearFromClass) {
  139. legacyAppearFromClass = toLegacyClass(appearFromClass)
  140. }
  141. if (!rawProps.leaveFromClass) {
  142. legacyLeaveFromClass = toLegacyClass(leaveFromClass)
  143. }
  144. }
  145. const durations = normalizeDuration(duration)
  146. const enterDuration = durations && durations[0]
  147. const leaveDuration = durations && durations[1]
  148. const {
  149. onBeforeEnter,
  150. onEnter,
  151. onEnterCancelled,
  152. onLeave,
  153. onLeaveCancelled,
  154. onBeforeAppear = onBeforeEnter,
  155. onAppear = onEnter,
  156. onAppearCancelled = onEnterCancelled
  157. } = baseProps
  158. const finishEnter = (el: Element, isAppear: boolean, done?: () => void) => {
  159. removeTransitionClass(el, isAppear ? appearToClass : enterToClass)
  160. removeTransitionClass(el, isAppear ? appearActiveClass : enterActiveClass)
  161. done && done()
  162. }
  163. const finishLeave = (
  164. el: Element & { _isLeaving?: boolean },
  165. done?: () => void
  166. ) => {
  167. el._isLeaving = false
  168. removeTransitionClass(el, leaveFromClass)
  169. removeTransitionClass(el, leaveToClass)
  170. removeTransitionClass(el, leaveActiveClass)
  171. done && done()
  172. }
  173. const makeEnterHook = (isAppear: boolean) => {
  174. return (el: Element, done: () => void) => {
  175. const hook = isAppear ? onAppear : onEnter
  176. const resolve = () => finishEnter(el, isAppear, done)
  177. callHook(hook, [el, resolve])
  178. nextFrame(() => {
  179. removeTransitionClass(el, isAppear ? appearFromClass : enterFromClass)
  180. if (__COMPAT__ && legacyClassEnabled) {
  181. const legacyClass = isAppear
  182. ? legacyAppearFromClass
  183. : legacyEnterFromClass
  184. if (legacyClass) {
  185. removeTransitionClass(el, legacyClass)
  186. }
  187. }
  188. addTransitionClass(el, isAppear ? appearToClass : enterToClass)
  189. if (!hasExplicitCallback(hook)) {
  190. whenTransitionEnds(el, type, enterDuration, resolve)
  191. }
  192. })
  193. }
  194. }
  195. return extend(baseProps, {
  196. onBeforeEnter(el) {
  197. callHook(onBeforeEnter, [el])
  198. addTransitionClass(el, enterFromClass)
  199. if (__COMPAT__ && legacyClassEnabled && legacyEnterFromClass) {
  200. addTransitionClass(el, legacyEnterFromClass)
  201. }
  202. addTransitionClass(el, enterActiveClass)
  203. },
  204. onBeforeAppear(el) {
  205. callHook(onBeforeAppear, [el])
  206. addTransitionClass(el, appearFromClass)
  207. if (__COMPAT__ && legacyClassEnabled && legacyAppearFromClass) {
  208. addTransitionClass(el, legacyAppearFromClass)
  209. }
  210. addTransitionClass(el, appearActiveClass)
  211. },
  212. onEnter: makeEnterHook(false),
  213. onAppear: makeEnterHook(true),
  214. onLeave(el: Element & { _isLeaving?: boolean }, done) {
  215. el._isLeaving = true
  216. const resolve = () => finishLeave(el, done)
  217. addTransitionClass(el, leaveFromClass)
  218. if (__COMPAT__ && legacyClassEnabled && legacyLeaveFromClass) {
  219. addTransitionClass(el, legacyLeaveFromClass)
  220. }
  221. // force reflow so *-leave-from classes immediately take effect (#2593)
  222. forceReflow()
  223. addTransitionClass(el, leaveActiveClass)
  224. nextFrame(() => {
  225. if (!el._isLeaving) {
  226. // cancelled
  227. return
  228. }
  229. removeTransitionClass(el, leaveFromClass)
  230. if (__COMPAT__ && legacyClassEnabled && legacyLeaveFromClass) {
  231. removeTransitionClass(el, legacyLeaveFromClass)
  232. }
  233. addTransitionClass(el, leaveToClass)
  234. if (!hasExplicitCallback(onLeave)) {
  235. whenTransitionEnds(el, type, leaveDuration, resolve)
  236. }
  237. })
  238. callHook(onLeave, [el, resolve])
  239. },
  240. onEnterCancelled(el) {
  241. finishEnter(el, false)
  242. callHook(onEnterCancelled, [el])
  243. },
  244. onAppearCancelled(el) {
  245. finishEnter(el, true)
  246. callHook(onAppearCancelled, [el])
  247. },
  248. onLeaveCancelled(el) {
  249. finishLeave(el)
  250. callHook(onLeaveCancelled, [el])
  251. }
  252. } as BaseTransitionProps<Element>)
  253. }
  254. function normalizeDuration(
  255. duration: TransitionProps['duration']
  256. ): [number, number] | null {
  257. if (duration == null) {
  258. return null
  259. } else if (isObject(duration)) {
  260. return [NumberOf(duration.enter), NumberOf(duration.leave)]
  261. } else {
  262. const n = NumberOf(duration)
  263. return [n, n]
  264. }
  265. }
  266. function NumberOf(val: unknown): number {
  267. const res = toNumber(val)
  268. if (__DEV__) {
  269. assertNumber(res, '<transition> explicit duration')
  270. }
  271. return res
  272. }
  273. export function addTransitionClass(el: Element, cls: string) {
  274. cls.split(/\s+/).forEach(c => c && el.classList.add(c))
  275. ;(
  276. (el as ElementWithTransition)[vtcKey] ||
  277. ((el as ElementWithTransition)[vtcKey] = new Set())
  278. ).add(cls)
  279. }
  280. export function removeTransitionClass(el: Element, cls: string) {
  281. cls.split(/\s+/).forEach(c => c && el.classList.remove(c))
  282. const _vtc = (el as ElementWithTransition)[vtcKey]
  283. if (_vtc) {
  284. _vtc.delete(cls)
  285. if (!_vtc!.size) {
  286. ;(el as ElementWithTransition)[vtcKey] = undefined
  287. }
  288. }
  289. }
  290. function nextFrame(cb: () => void) {
  291. requestAnimationFrame(() => {
  292. requestAnimationFrame(cb)
  293. })
  294. }
  295. let endId = 0
  296. function whenTransitionEnds(
  297. el: Element & { _endId?: number },
  298. expectedType: TransitionProps['type'] | undefined,
  299. explicitTimeout: number | null,
  300. resolve: () => void
  301. ) {
  302. const id = (el._endId = ++endId)
  303. const resolveIfNotStale = () => {
  304. if (id === el._endId) {
  305. resolve()
  306. }
  307. }
  308. if (explicitTimeout) {
  309. return setTimeout(resolveIfNotStale, explicitTimeout)
  310. }
  311. const { type, timeout, propCount } = getTransitionInfo(el, expectedType)
  312. if (!type) {
  313. return resolve()
  314. }
  315. const endEvent = type + 'end'
  316. let ended = 0
  317. const end = () => {
  318. el.removeEventListener(endEvent, onEnd)
  319. resolveIfNotStale()
  320. }
  321. const onEnd = (e: Event) => {
  322. if (e.target === el && ++ended >= propCount) {
  323. end()
  324. }
  325. }
  326. setTimeout(() => {
  327. if (ended < propCount) {
  328. end()
  329. }
  330. }, timeout + 1)
  331. el.addEventListener(endEvent, onEnd)
  332. }
  333. interface CSSTransitionInfo {
  334. type: AnimationTypes | null
  335. propCount: number
  336. timeout: number
  337. hasTransform: boolean
  338. }
  339. type AnimationProperties = 'Delay' | 'Duration'
  340. type StylePropertiesKey =
  341. | `${AnimationTypes}${AnimationProperties}`
  342. | `${typeof TRANSITION}Property`
  343. export function getTransitionInfo(
  344. el: Element,
  345. expectedType?: TransitionProps['type']
  346. ): CSSTransitionInfo {
  347. const styles = window.getComputedStyle(el) as Pick<
  348. CSSStyleDeclaration,
  349. StylePropertiesKey
  350. >
  351. // JSDOM may return undefined for transition properties
  352. const getStyleProperties = (key: StylePropertiesKey) =>
  353. (styles[key] || '').split(', ')
  354. const transitionDelays = getStyleProperties(`${TRANSITION}Delay`)
  355. const transitionDurations = getStyleProperties(`${TRANSITION}Duration`)
  356. const transitionTimeout = getTimeout(transitionDelays, transitionDurations)
  357. const animationDelays = getStyleProperties(`${ANIMATION}Delay`)
  358. const animationDurations = getStyleProperties(`${ANIMATION}Duration`)
  359. const animationTimeout = getTimeout(animationDelays, animationDurations)
  360. let type: CSSTransitionInfo['type'] = null
  361. let timeout = 0
  362. let propCount = 0
  363. /* istanbul ignore if */
  364. if (expectedType === TRANSITION) {
  365. if (transitionTimeout > 0) {
  366. type = TRANSITION
  367. timeout = transitionTimeout
  368. propCount = transitionDurations.length
  369. }
  370. } else if (expectedType === ANIMATION) {
  371. if (animationTimeout > 0) {
  372. type = ANIMATION
  373. timeout = animationTimeout
  374. propCount = animationDurations.length
  375. }
  376. } else {
  377. timeout = Math.max(transitionTimeout, animationTimeout)
  378. type =
  379. timeout > 0
  380. ? transitionTimeout > animationTimeout
  381. ? TRANSITION
  382. : ANIMATION
  383. : null
  384. propCount = type
  385. ? type === TRANSITION
  386. ? transitionDurations.length
  387. : animationDurations.length
  388. : 0
  389. }
  390. const hasTransform =
  391. type === TRANSITION &&
  392. /\b(transform|all)(,|$)/.test(
  393. getStyleProperties(`${TRANSITION}Property`).toString()
  394. )
  395. return {
  396. type,
  397. timeout,
  398. propCount,
  399. hasTransform
  400. }
  401. }
  402. function getTimeout(delays: string[], durations: string[]): number {
  403. while (delays.length < durations.length) {
  404. delays = delays.concat(delays)
  405. }
  406. return Math.max(...durations.map((d, i) => toMs(d) + toMs(delays[i])))
  407. }
  408. // Old versions of Chromium (below 61.0.3163.100) formats floating pointer
  409. // numbers in a locale-dependent way, using a comma instead of a dot.
  410. // If comma is not replaced with a dot, the input will be rounded down
  411. // (i.e. acting as a floor function) causing unexpected behaviors
  412. function toMs(s: string): number {
  413. // #8409 default value for CSS durations can be 'auto'
  414. if (s === 'auto') return 0
  415. return Number(s.slice(0, -1).replace(',', '.')) * 1000
  416. }
  417. // synchronously force layout to put elements into a certain state
  418. export function forceReflow() {
  419. return document.body.offsetHeight
  420. }