Files
headlessui/packages/@headlessui-react/src/components/transition/utils/transition.ts
T
Robin Malfait 6b6c259010 Introduce CSS based transitions (#3273)
* simplify `useFlags`

* add new `useTransitionData` hook

* use new `useTransitionData` hook

* add ability to cancel transitions mid-transition

* handle cancellations in both directions properly

* re-use existing `prepareTransition`

* expose `data-*` attributes for transitions in `<Transition />` component

* update tests to reflect added data attributes

* update changelog

* only call `getAnimations` if available

This has been around since 2020, but JSDOM doesn't know about this yet,
so tests using JSDOM will fail otherwise.
2024-06-11 17:53:21 +02:00

211 lines
6.1 KiB
TypeScript

import type { MutableRefObject } from 'react'
import { disposables } from '../../../utils/disposables'
import { match } from '../../../utils/match'
import { once } from '../../../utils/once'
function addClasses(node: HTMLElement, ...classes: string[]) {
node && classes.length > 0 && node.classList.add(...classes)
}
function removeClasses(node: HTMLElement, ...classes: string[]) {
node && classes.length > 0 && node.classList.remove(...classes)
}
export function waitForTransition(node: HTMLElement, _done: () => void) {
let done = once(_done)
let d = disposables()
if (!node) return d.dispose
// Safari returns a comma separated list of values, so let's sort them and take the highest value.
let { transitionDuration, transitionDelay } = getComputedStyle(node)
let [durationMs, delayMs] = [transitionDuration, transitionDelay].map((value) => {
let [resolvedValue = 0] = value
.split(',')
// Remove falsy we can't work with
.filter(Boolean)
// Values are returned as `0.3s` or `75ms`
.map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
.sort((a, z) => z - a)
return resolvedValue
})
let totalDuration = durationMs + delayMs
if (totalDuration !== 0) {
if (process.env.NODE_ENV === 'test') {
let dispose = d.setTimeout(() => {
done()
dispose()
}, totalDuration)
} else {
let disposeGroup = d.group((d) => {
// Mark the transition as done when the timeout is reached. This is a fallback in case the
// transitionrun event is not fired.
let cancelTimeout = d.setTimeout(() => {
done()
d.dispose()
}, totalDuration)
// The moment the transitionrun event fires, we should cleanup the timeout fallback, because
// then we know that we can use the native transition events because something is
// transitioning.
d.addEventListener(node, 'transitionrun', (event) => {
if (event.target !== event.currentTarget) return
cancelTimeout()
d.addEventListener(node, 'transitioncancel', (event) => {
if (event.target !== event.currentTarget) return
done()
disposeGroup()
})
})
})
d.addEventListener(node, 'transitionend', (event) => {
if (event.target !== event.currentTarget) return
done()
d.dispose()
})
}
} else {
// No transition is happening, so we should cleanup already. Otherwise we have to wait until we
// get disposed.
done()
}
return d.dispose
}
export function transition(
node: HTMLElement,
{
direction,
done,
classes,
inFlight,
}: {
direction: 'enter' | 'leave'
done?: () => void
classes: {
base: string[]
enter: string[]
enterFrom: string[]
enterTo: string[]
leave: string[]
leaveFrom: string[]
leaveTo: string[]
entered: string[]
}
inFlight?: MutableRefObject<boolean>
}
) {
let d = disposables()
let _done = done !== undefined ? once(done) : () => {}
// When using unmount={false}, when the element is "hidden", then we apply a `style.display =
// 'none'` and a `hidden` attribute. Let's remove that in case we want to make an enter
// transition. It can happen that React is removing this a bit too late causing the element to not
// transition at all.
if (direction === 'enter') {
node.removeAttribute('hidden')
node.style.display = ''
}
let base = match(direction, {
enter: () => classes.enter,
leave: () => classes.leave,
})
let to = match(direction, {
enter: () => classes.enterTo,
leave: () => classes.leaveTo,
})
let from = match(direction, {
enter: () => classes.enterFrom,
leave: () => classes.leaveFrom,
})
// Prepare the transitions by ensuring that all the "before" classes are
// applied and flushed to the DOM.
prepareTransition(node, {
prepare() {
removeClasses(
node,
...classes.base,
...classes.enter,
...classes.enterTo,
...classes.enterFrom,
...classes.leave,
...classes.leaveFrom,
...classes.leaveTo,
...classes.entered
)
addClasses(node, ...classes.base, ...base, ...from)
},
inFlight,
})
// Mark the transition as in-flight
if (inFlight) inFlight.current = true
// This is a workaround for a bug in all major browsers.
//
// 1. When an element is just mounted
// 2. And you apply a transition to it (e.g.: via a class)
// 3. And you're using `getComputedStyle` and read any returned value
// 4. Then the `transition` immediately jumps to the end state
//
// This means that no transition happens at all. To fix this, we delay the
// actual transition by one frame.
d.nextFrame(() => {
// Wait for the transition, once the transition is complete we can cleanup.
// This is registered first to prevent race conditions, otherwise it could
// happen that the transition is already done before we start waiting for
// the actual event.
d.add(
waitForTransition(node, () => {
removeClasses(node, ...classes.base, ...base)
addClasses(node, ...classes.base, ...classes.entered, ...to)
// Mark the transition as done.
if (inFlight) inFlight.current = false
return _done()
})
)
// Initiate the transition by applying the new classes.
removeClasses(node, ...classes.base, ...base, ...from)
addClasses(node, ...classes.base, ...base, ...to)
})
return d.dispose
}
export function prepareTransition(
node: HTMLElement,
{ inFlight, prepare }: { inFlight?: MutableRefObject<boolean>; prepare: () => void }
) {
// If we are already transitioning, then we don't need to force cancel the
// current transition (by triggering a reflow).
if (inFlight?.current) {
prepare()
return
}
let previous = node.style.transition
// Force cancel current transition
node.style.transition = 'none'
prepare()
// Trigger a reflow, flushing the CSS changes
node.offsetHeight
// Reset the transition to what it was before
node.style.transition = previous
}