UNPKG

reka-ui

Version:

Vue port for Radix UI Primitives.

175 lines (160 loc) 6.22 kB
import type { Ref } from 'vue' import { useStateMachine } from '@/shared' import { defaultWindow } from '@vueuse/core' import { isClient } from '@vueuse/shared' import { computed, nextTick, onUnmounted, ref, watch } from 'vue' export function usePresence( present: Ref<boolean>, node: Ref<HTMLElement | undefined>, ) { const stylesRef = ref<CSSStyleDeclaration>({} as any) const prevAnimationNameRef = ref<string>('none') const prevPresentRef = ref(present) const initialState = present.value ? 'mounted' : 'unmounted' let timeoutId: number | undefined const ownerWindow = node.value?.ownerDocument.defaultView ?? defaultWindow const { state, dispatch } = useStateMachine(initialState, { mounted: { UNMOUNT: 'unmounted', ANIMATION_OUT: 'unmountSuspended', }, unmountSuspended: { MOUNT: 'mounted', ANIMATION_END: 'unmounted', }, unmounted: { MOUNT: 'mounted', }, }) const dispatchCustomEvent = (name: 'enter' | 'after-enter' | 'leave' | 'after-leave') => { // We only dispatch this event because CustomEvent is not available in Node18 // https://github.com/unovue/reka-ui/issues/930 if (isClient) { const customEvent = new CustomEvent(name, { bubbles: false, cancelable: false }) node.value?.dispatchEvent(customEvent) } } watch( present, async (currentPresent, prevPresent) => { const hasPresentChanged = prevPresent !== currentPresent await nextTick() if (hasPresentChanged) { const prevAnimationName = prevAnimationNameRef.value const currentAnimationName = getAnimationName(node.value) if (currentPresent) { dispatch('MOUNT') dispatchCustomEvent('enter') if (currentAnimationName === 'none') dispatchCustomEvent('after-enter') } else if ( currentAnimationName === 'none' || currentAnimationName === 'undefined' || stylesRef.value?.display === 'none' ) { // If there is no exit animation or the element is hidden, animations won't run // so we unmount instantly rv dispatch('UNMOUNT') dispatchCustomEvent('leave') dispatchCustomEvent('after-leave') } else { /** * When `present` changes to `false`, we check changes to animation-name to * determine whether an animation has started. We chose this approach (reading * computed styles) because there is no `animationrun` event and `animationstart` * fires after `animation-delay` has expired which would be too late. */ const isAnimating = prevAnimationName !== currentAnimationName if (prevPresent && isAnimating) { dispatch('ANIMATION_OUT') dispatchCustomEvent('leave') } else { dispatch('UNMOUNT') dispatchCustomEvent('after-leave') } } } }, { immediate: true }, ) /** * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we * make sure we only trigger ANIMATION_END for the currently active animation. */ const handleAnimationEnd = (event: AnimationEvent) => { const currentAnimationName = getAnimationName(node.value) const isCurrentAnimation = currentAnimationName.includes( CSS.escape(event.animationName), ) const directionName = state.value === 'mounted' ? 'enter' : 'leave' if (event.target === node.value && isCurrentAnimation) { dispatchCustomEvent(`after-${directionName}`) dispatch('ANIMATION_END') if (!prevPresentRef.value) { const currentFillMode = node.value.style.animationFillMode node.value.style.animationFillMode = 'forwards' // Reset the style after the node had time to unmount (for cases // where the component chooses not to unmount). Doing this any // sooner than `setTimeout` (e.g. with `requestAnimationFrame`) // still causes a flash. timeoutId = ownerWindow?.setTimeout(() => { if (node.value?.style.animationFillMode === 'forwards') { node.value.style.animationFillMode = currentFillMode } }) } } // if no animation, immediately trigger 'ANIMATION_END' if (event.target === node.value && currentAnimationName === 'none') dispatch('ANIMATION_END') } const handleAnimationStart = (event: AnimationEvent) => { if (event.target === node.value) { // if animation occurred, store its name as the previous animation. prevAnimationNameRef.value = getAnimationName(node.value) } } const watcher = watch( node, (newNode, oldNode) => { if (newNode) { stylesRef.value = getComputedStyle(newNode) newNode.addEventListener('animationstart', handleAnimationStart) newNode.addEventListener('animationcancel', handleAnimationEnd) newNode.addEventListener('animationend', handleAnimationEnd) } else { // Transition to the unmounted state if the node is removed prematurely. // We avoid doing so during cleanup as the node may change but still exist. dispatch('ANIMATION_END') if (timeoutId !== undefined) ownerWindow?.clearTimeout(timeoutId) oldNode?.removeEventListener('animationstart', handleAnimationStart) oldNode?.removeEventListener('animationcancel', handleAnimationEnd) oldNode?.removeEventListener('animationend', handleAnimationEnd) } }, { immediate: true }, ) const stateWatcher = watch(state, () => { const currentAnimationName = getAnimationName(node.value) prevAnimationNameRef.value = state.value === 'mounted' ? currentAnimationName : 'none' }) onUnmounted(() => { watcher() stateWatcher() }) const isPresent = computed(() => ['mounted', 'unmountSuspended'].includes(state.value), ) return { isPresent, } } function getAnimationName(node?: HTMLElement) { return node ? getComputedStyle(node).animationName || 'none' : 'none' }