Implement <Transition /> and <TransitionChild /> on top of data attributes (#3303)

* add optional `start` and `end` events to `useTransitionData`

This will be used when we implement the `<Transition />` component
purely using the `useTransitionData` information. But because there is a
hierarchy between `<Transition />` and `<TransitionChild />` we need to
know when transitions start and end.

* implement `<Transition />` and `<TransitionChild />` on top of `useTransitionData()`

* update tests

Due to a timing issue bug, we updated the snapshot tests in
https://github.com/tailwindlabs/headlessui/pull/3273 incorrectly so this
commit fixes that which is why there are a lot of changes.

Most tests have `show={true}` but not `appear` which means that they
should _not_ transition which means that no data attributes should be
present.

* wait a microTask to ensure that `prepare()` has the time to render

Now that we set state instead of mutating the DOM directly we need to
wait a tiny bit and then we can trigger the transition to ensure
a smooth transition.

* cleanup `prepareTransition` now that it returns a cleanup function

* move `waitForTransition` and `prepareTransition` into `useTransitionData`

* remove existing `useTransition` hook and related utilities

* rename `useTransitionData` to `useTransition`

* update changelog

* Update packages/@headlessui-react/src/components/transition/transition.tsx

Co-authored-by: Jordan Pittman <jordan@cryptica.me>

* add missing `TransitionState.Enter`

This makes sure that the `Enter` state is applied initially when it has
to.

This also means that we can simplify the `prepareTransition` code again
because we don't need to wait for the next microTask which made sure
`TransitionState.Enter` was available.

* add transition playground page with both APIs

* update tests to reflect latest bug fix

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Robin Malfait
2024-06-19 23:42:14 +02:00
committed by GitHub
parent 20920492b5
commit 29e7d94503
14 changed files with 480 additions and 952 deletions
+1
View File
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Use `useId` instead of React internals (for React 19 compatibility) ([#3254](https://github.com/tailwindlabs/headlessui/pull/3254))
- Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259))
- Cancel outside click behavior on touch devices when scrolling ([#3266](https://github.com/tailwindlabs/headlessui/pull/3266))
- Correctly apply conditional classses when using `<Transition />` and `<TransitionChild />` components ([#3303](https://github.com/tailwindlabs/headlessui/pull/3303))
### Changed
@@ -41,7 +41,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useScrollLock } from '../../hooks/use-scroll-lock'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
import { useTransition, type TransitionData } from '../../hooks/use-transition'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useWatch } from '../../hooks/use-watch'
import { useDisabled } from '../../internal/disabled'
@@ -1610,7 +1610,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
let ownerDocument = useOwnerDocument(data.optionsRef)
let usesOpenClosedState = useOpenClosed()
let [visible, transitionData] = useTransitionData(
let [visible, transitionData] = useTransition(
transition,
data.optionsRef,
usesOpenClosedState !== null
@@ -24,7 +24,7 @@ import { useEvent } from '../../hooks/use-event'
import { useId } from '../../hooks/use-id'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
import { useTransition, type TransitionData } from '../../hooks/use-transition'
import { CloseProvider } from '../../internal/close-provider'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import type { Props } from '../../types'
@@ -458,7 +458,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
}, [id, dispatch])
let usesOpenClosedState = useOpenClosed()
let [visible, transitionData] = useTransitionData(
let [visible, transitionData] = useTransition(
transition,
state.panelRef,
usesOpenClosedState !== null
@@ -42,7 +42,7 @@ import { useScrollLock } from '../../hooks/use-scroll-lock'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useTextValue } from '../../hooks/use-text-value'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
import { useTransition, type TransitionData } from '../../hooks/use-transition'
import { useDisabled } from '../../internal/disabled'
import {
FloatingProvider,
@@ -919,7 +919,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
let ownerDocument = useOwnerDocument(data.optionsRef)
let usesOpenClosedState = useOpenClosed()
let [visible, transitionData] = useTransitionData(
let [visible, transitionData] = useTransition(
transition,
data.optionsRef,
usesOpenClosedState !== null
@@ -37,7 +37,7 @@ import { useScrollLock } from '../../hooks/use-scroll-lock'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useTextValue } from '../../hooks/use-text-value'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
import { useTransition, type TransitionData } from '../../hooks/use-transition'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import {
FloatingProvider,
@@ -612,7 +612,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
}
let usesOpenClosedState = useOpenClosed()
let [visible, transitionData] = useTransitionData(
let [visible, transitionData] = useTransition(
transition,
state.itemsRef,
usesOpenClosedState !== null
@@ -36,7 +36,7 @@ import { useMainTreeNode, useRootContainers } from '../../hooks/use-root-contain
import { useScrollLock } from '../../hooks/use-scroll-lock'
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
import { Direction as TabDirection, useTabDirection } from '../../hooks/use-tab-direction'
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
import { useTransition, type TransitionData } from '../../hooks/use-transition'
import { CloseProvider } from '../../internal/close-provider'
import {
FloatingProvider,
@@ -750,7 +750,7 @@ function OverlayFn<TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG>(
let overlayRef = useSyncRefs(ref, internalOverlayRef)
let usesOpenClosedState = useOpenClosed()
let [visible, transitionData] = useTransitionData(
let [visible, transitionData] = useTransition(
transition,
internalOverlayRef,
usesOpenClosedState !== null
@@ -862,7 +862,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
}, [id, dispatch])
let usesOpenClosedState = useOpenClosed()
let [visible, transitionData] = useTransitionData(
let [visible, transitionData] = useTransition(
transition,
internalPanelRef,
usesOpenClosedState !== null
@@ -4,29 +4,11 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
<div
class="My Page"
>
<article
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<aside
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<article>
<aside>
Sidebar
</aside>
<section
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<section>
Content
</section>
</article>
@@ -37,29 +19,11 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
<div
class="My Page"
>
<div
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<aside
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<div>
<aside>
Sidebar
</aside>
<section
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<section>
Content
</section>
</div>
@@ -70,29 +34,11 @@ exports[`Setup API nested should be possible to nest transition components 1`] =
<div
class="My Page"
>
<div
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<div
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<div>
<div>
Sidebar
</div>
<div
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<div>
Content
</div>
</div>
@@ -103,26 +49,11 @@ exports[`Setup API nested should be possible to use render props on the Transiti
<div
class="My Page"
>
<article
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
>
<aside
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
>
<article>
<aside>
Sidebar
</aside>
<section
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
>
<section>
Content
</section>
</article>
@@ -133,27 +64,11 @@ exports[`Setup API nested should be possible to use render props on the Transiti
<div
class="My Page"
>
<div
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<aside
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
>
<div>
<aside>
Sidebar
</aside>
<section
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
>
<section>
Content
</section>
</div>
@@ -167,24 +82,13 @@ exports[`Setup API nested should yell at us when we forgot to forward a ref on t
exports[`Setup API nested should yell at us when we forgot to forward the ref on one of the Transition.Child components 1`] = `"Did you forget to passthrough the \`ref\` to the actual DOM node?"`;
exports[`Setup API shallow should be possible to change the underlying DOM tag 1`] = `
<span
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<span>
Children
</span>
`;
exports[`Setup API shallow should be possible to use a render prop 1`] = `
<span
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
>
<span>
Children
</span>
`;
@@ -192,12 +96,7 @@ exports[`Setup API shallow should be possible to use a render prop 1`] = `
exports[`Setup API shallow should passthrough all the props (that we do not use internally) 1`] = `
<div
class="text-blue-400"
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
id="root"
style=""
>
Children
</div>
@@ -206,25 +105,14 @@ exports[`Setup API shallow should passthrough all the props (that we do not use
exports[`Setup API shallow should passthrough all the props (that we do not use internally) even when using an \`as\` prop 1`] = `
<a
class="text-blue-400"
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
href="/"
style=""
>
Children
</a>
`;
exports[`Setup API shallow should render another component if the \`as\` prop is used and its children by default 1`] = `
<a
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
<a>
Children
</a>
`;
@@ -234,7 +122,14 @@ exports[`Setup API shallow should render nothing when the show prop is false 1`]
exports[`Setup API shallow should yell at us when we forget to forward the ref when using a render prop 1`] = `"Did you forget to passthrough the \`ref\` to the actual DOM node?"`;
exports[`Setup API transition classes should be possible to passthrough the transition classes 1`] = `
<div>
Children
</div>
`;
exports[`Setup API transition classes should be possible to passthrough the transition classes and immediately apply the enter transitions when appear is set to true 1`] = `
<div
class="enter enter-from"
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
@@ -245,15 +140,4 @@ exports[`Setup API transition classes should be possible to passthrough the tran
</div>
`;
exports[`Setup API transition classes should be possible to passthrough the transition classes and immediately apply the enter transitions when appear is set to true 1`] = `
<div
class="enter enter-from"
data-closed=""
data-headlessui-state="closed"
style=""
>
Children
</div>
`;
exports[`should yell at us when we forget the required show prop 1`] = `"A <Transition /> is used but it is missing a \`show={true | false}\` prop."`;
@@ -358,11 +358,6 @@ describe('Setup API', () => {
<div
class="foo1
foo2"
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
data-transition=""
style=""
>
Children
</div>
@@ -371,9 +366,6 @@ describe('Setup API', () => {
await click(getByText('toggle'))
// TODO: This is not quite right
// The `foo1\nfoo2` should be gone
// I think this is a quirk of JSDOM
expect(container.firstChild).toMatchInlineSnapshot(`
<div>
<button>
@@ -381,10 +373,9 @@ describe('Setup API', () => {
</button>
<div
class="foo1
foo2 foo1 foo2 leave"
data-closed=""
data-enter=""
data-headlessui-state="closed enter transition"
foo2 leave"
data-headlessui-state="leave transition"
data-leave=""
data-transition=""
style=""
>
@@ -4,6 +4,7 @@ import React, {
Fragment,
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
@@ -13,7 +14,6 @@ import React, {
} from 'react'
import { useDisposables } from '../../hooks/use-disposables'
import { useEvent } from '../../hooks/use-event'
import { useFlags } from '../../hooks/use-flags'
import { useIsMounted } from '../../hooks/use-is-mounted'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useLatestValue } from '../../hooks/use-latest-value'
@@ -21,7 +21,6 @@ import { useOnDisappear } from '../../hooks/use-on-disappear'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useTransition } from '../../hooks/use-transition'
import { useTransitionData } from '../../hooks/use-transition-data'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import type { Props, ReactTag } from '../../types'
import { classNames } from '../../utils/class-names'
@@ -29,6 +28,7 @@ import { match } from '../../utils/match'
import {
RenderFeatures,
RenderStrategy,
compact,
forwardRefWithAs,
render,
type HasDisplayName,
@@ -38,17 +38,7 @@ import {
type ContainerElement = MutableRefObject<HTMLElement | null>
type TransitionDirection = 'enter' | 'leave' | 'idle'
/**
* Split class lists by whitespace
*
* We can't check for just spaces as all whitespace characters are
* invalid in a class name, so we have to split on ANY whitespace.
*/
function splitClasses(classes: string = '') {
return classes.split(/\s+/).filter((className) => className.length > 1)
}
type TransitionDirection = 'enter' | 'leave'
/**
* Check if we should forward the ref to the child element or not. This is to
@@ -224,11 +214,7 @@ function useNesting(done?: () => void, parent?: NestingContextValues) {
let chains = useRef<
Record<TransitionDirection, [identifier: ContainerElement, promise: Promise<void>][]>
>({
enter: [],
leave: [],
idle: [],
})
>({ enter: [], leave: [] })
let onStart = useEvent(
(
@@ -328,13 +314,13 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
leaveTo,
// @ts-expect-error
...rest
...theirProps
} = props as typeof props
let container = useRef<HTMLElement | null>(null)
let requiresRef = shouldForwardRef(props)
let transitionRef = useSyncRefs(...(requiresRef ? [container, ref] : ref === null ? [] : [ref]))
let strategy = rest.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
let strategy = theirProps.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
let { show, appear, initial } = useTransitionContext()
@@ -362,24 +348,6 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
})
}, [state, container, register, unregister, show, strategy])
let classes = useLatestValue({
base: splitClasses(rest.className),
enter: splitClasses(enter),
enterFrom: splitClasses(enterFrom),
enterTo: splitClasses(enterTo),
entered: splitClasses(entered),
leave: splitClasses(leave),
leaveFrom: splitClasses(leaveFrom),
leaveTo: splitClasses(leaveTo),
})
let events = useLatestValue({
beforeEnter,
afterEnter,
beforeLeave,
afterLeave,
})
let ready = useServerHandoffComplete()
useIsoMorphicEffect(() => {
@@ -394,43 +362,6 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
let skip = initial && !appear
let immediate = appear && show && initial
let transitionDirection = (() => {
if (immediate) return 'enter'
if (!ready) return 'idle'
if (skip) return 'idle'
return show ? 'enter' : 'leave'
})() as TransitionDirection
let transitionStateFlags = useFlags(0)
let beforeEvent = useEvent((direction: TransitionDirection) => {
return match(direction, {
enter: () => {
transitionStateFlags.addFlag(State.Opening)
events.current.beforeEnter?.()
},
leave: () => {
transitionStateFlags.addFlag(State.Closing)
events.current.beforeLeave?.()
},
idle: () => {},
})
})
let afterEvent = useEvent((direction: TransitionDirection) => {
return match(direction, {
enter: () => {
transitionStateFlags.removeFlag(State.Opening)
events.current.afterEnter?.()
},
leave: () => {
transitionStateFlags.removeFlag(State.Closing)
events.current.afterLeave?.()
},
idle: () => {},
})
})
let isTransitioning = useRef(false)
let nesting = useNesting(() => {
@@ -443,77 +374,97 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
unregister(container)
}, parentNesting)
useTransition({
container,
classes,
direction: transitionDirection,
onStart: useLatestValue((direction) => {
isTransitioning.current = true
nesting.onStart(container, direction, beforeEvent)
}),
onStop: useLatestValue((direction) => {
isTransitioning.current = false
nesting.onStop(container, direction, afterEvent)
let start = useEvent((show: boolean) => {
isTransitioning.current = true
let direction: TransitionDirection = show ? 'enter' : 'leave'
if (direction === 'leave' && !hasChildren(nesting)) {
// When we don't have children anymore we can safely unregister from the
// parent and hide ourselves.
setState(TreeStates.Hidden)
unregister(container)
}
}),
nesting.onStart(container, direction, (direction) => {
if (direction === 'enter') beforeEnter?.()
else if (direction === 'leave') beforeLeave?.()
})
})
let theirProps = rest
let ourProps = { ref: transitionRef }
let end = useEvent((show: boolean) => {
let direction: TransitionDirection = show ? 'enter' : 'leave'
// Already apply the `enter` and `enterFrom` on the server if required
if (immediate) {
theirProps = {
...theirProps,
className: classNames(rest.className, ...classes.current.enter, ...classes.current.enterFrom),
isTransitioning.current = false
nesting.onStop(container, direction, (direction) => {
if (direction === 'enter') afterEnter?.()
else if (direction === 'leave') afterLeave?.()
})
if (direction === 'leave' && !hasChildren(nesting)) {
// When we don't have children anymore we can safely unregister from the
// parent and hide ourselves.
setState(TreeStates.Hidden)
unregister(container)
}
}
})
// If we are re-rendering while we are transitioning, then we should ensure that the classes are
// not mutated by React itself because we are handling the transition ourself.
else if (isTransitioning.current) {
// When we re-render while we are in the middle of the transition, then we should take the
// incoming className and the current classes that are applied.
//
// This is a bit dirty, but we need to make sure React is not applying changes to the class
// attribute while we are transitioning.
theirProps.className = classNames(rest.className, container.current?.className)
if (theirProps.className === '') delete theirProps.className
}
useEffect(() => {
if (requiresRef) return
// If we were never transitioning, or we're not transitioning anymore, then
// apply the `enterTo` and `leaveTo` classes as the final state.
else {
theirProps.className = classNames(
rest.className,
container.current?.className,
...match(transitionDirection, {
enter: [...classes.current.enterTo, ...classes.current.entered],
leave: classes.current.leaveTo,
idle: [],
})
)
if (theirProps.className === '') delete theirProps.className
}
// When we don't transition, then we can complete the transition
// immediately.
start(show)
end(show)
}, [show, requiresRef])
let [, slot] = useTransitionData(ready, container, show)
let enabled = (() => {
// If we don't require a ref, then we can't transition.
if (!requiresRef) return false
// If the server handoff isn't completed yet, we can't transition.
if (!ready) return false
// If we start in a `show` state but without the `appear` prop, then we skip
// the initial transition.
if (skip) return false
return true
})()
// Ignoring the `visible` state because this doesn't handle the hierarchy. If
// a leave transition on the `<Transition>` is done, but there is still a
// child `<TransitionChild>` busy, then `visible` would be `false`, while
// `state` would still be `TreeStates.Visible`.
let [, slot] = useTransition(enabled, container, show, { start, end })
let ourProps = compact({
ref: transitionRef,
className:
classNames(
// Incoming classes if any
theirProps.className,
// Apply these classes immediately
immediate && enter,
immediate && enterFrom,
// Map data attributes to `enter`, `enterFrom` and `enterTo` classes
slot.enter && enter,
slot.enter && slot.closed && enterFrom,
slot.enter && !slot.closed && enterTo,
// Map data attributes to `leave`, `leaveFrom` and `leaveTo` classes
slot.leave && leave,
slot.leave && !slot.closed && leaveFrom,
slot.leave && slot.closed && leaveTo,
// Map data attributes to `entered` class (backwards compatibility)
!slot.transition && show && entered
)?.trim() || undefined, // If `className` is an empty string, we can omit it
})
let openClosedState = 0
if (state === TreeStates.Visible) openClosedState |= State.Open
if (state === TreeStates.Hidden) openClosedState |= State.Closed
if (slot.enter) openClosedState |= State.Opening
if (slot.leave) openClosedState |= State.Closing
return (
<NestingContext.Provider value={nesting}>
<OpenClosedProvider
value={
match(state, {
[TreeStates.Visible]: State.Open,
[TreeStates.Hidden]: State.Closed,
}) | transitionStateFlags.flags
}
>
<OpenClosedProvider value={openClosedState}>
{render({
ourProps,
theirProps,
@@ -1,179 +0,0 @@
import { reportChanges } from '../../../test-utils/report-dom-node-changes'
import { disposables } from '../../../utils/disposables'
import { transition } from './transition'
beforeEach(() => {
document.body.innerHTML = ''
})
it('should be possible to transition', async () => {
let d = disposables()
let snapshots: { content: string; recordedAt: bigint }[] = []
let element = document.createElement('div')
document.body.appendChild(element)
d.add(
reportChanges(
() => document.body.innerHTML,
(content) => {
snapshots.push({
content,
recordedAt: process.hrtime.bigint(),
})
}
)
)
await new Promise<void>((resolve) => {
transition(element, {
direction: 'enter', // Show
classes: {
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
enterTo: ['enterTo'],
leave: [],
leaveFrom: [],
leaveTo: [],
entered: ['entered'],
},
done: resolve,
})
})
await new Promise((resolve) => d.nextFrame(resolve))
// Initial render:
expect(snapshots[0].content).toEqual('<div></div>')
// Start of transition
expect(snapshots[1].content).toEqual('<div style="" class="enter enterFrom"></div>')
// NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
// necessary to put the classes on the element and immediately remove them.
// Cleanup phase
expect(snapshots[2].content).toEqual('<div style="" class="entered enterTo enter"></div>')
d.dispose()
})
it('should wait the correct amount of time to finish a transition', async () => {
let d = disposables()
let snapshots: { content: string; recordedAt: bigint }[] = []
let element = document.createElement('div')
document.body.appendChild(element)
let duration = 20
element.style.transitionDuration = `${duration}ms`
d.add(
reportChanges(
() => document.body.innerHTML,
(content) => {
snapshots.push({
content,
recordedAt: process.hrtime.bigint(),
})
}
)
)
await new Promise<void>((resolve) => {
transition(element, {
direction: 'enter', // Show
classes: {
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
enterTo: ['enterTo'],
leave: [],
leaveFrom: [],
leaveTo: [],
entered: ['entered'],
},
done: resolve,
})
})
await new Promise((resolve) => d.nextFrame(resolve))
// Initial render:
expect(snapshots[0].content).toEqual(`<div style="transition-duration: ${duration}ms;"></div>`)
// Start of transition
expect(snapshots[1].content).toEqual(
`<div style="transition-duration: ${duration}ms;" class="enter enterFrom"></div>`
)
expect(snapshots[2].content).toEqual(
`<div style="transition-duration: ${duration}ms;" class="enter enterTo"></div>`
)
let estimatedDuration = Number(
(snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
BigInt(1e6)
)
expect(estimatedDuration).toBeWithinRenderFrame(duration)
// Cleanup phase
expect(snapshots[3].content).toEqual(
`<div style="transition-duration: ${duration}ms;" class="enterTo entered"></div>`
)
})
it('should keep the delay time into account', async () => {
let d = disposables()
let snapshots: { content: string; recordedAt: bigint }[] = []
let element = document.createElement('div')
document.body.appendChild(element)
let duration = 20
let delayDuration = 100
element.style.transitionDuration = `${duration}ms`
element.style.transitionDelay = `${delayDuration}ms`
d.add(
reportChanges(
() => document.body.innerHTML,
(content) => {
snapshots.push({
content,
recordedAt: process.hrtime.bigint(),
})
}
)
)
await new Promise<void>((resolve) => {
transition(element, {
direction: 'enter', // Show
classes: {
base: [],
enter: ['enter'],
enterFrom: ['enterFrom'],
enterTo: ['enterTo'],
leave: [],
leaveFrom: [],
leaveTo: [],
entered: ['entered'],
},
done: resolve,
})
})
await new Promise((resolve) => d.nextFrame(resolve))
let estimatedDuration = Number(
(snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
BigInt(1e6)
)
expect(estimatedDuration).toBeWithinRenderFrame(duration + delayDuration)
})
@@ -1,210 +0,0 @@
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
}
@@ -1,213 +0,0 @@
import { useRef, useState, type MutableRefObject } from 'react'
import { prepareTransition, waitForTransition } from '../components/transition/utils/transition'
import { disposables } from '../utils/disposables'
import { useDisposables } from './use-disposables'
import { useFlags } from './use-flags'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
/**
* ```
*
* Closed Closed
*
*
* Frame Frame Frame Frame Frame Frame
*
*
* Enter Leave
*
*
* Transition Transition
*
*
* Applied when `Enter` or `Leave` is applied.
* ```
*/
enum TransitionState {
None = 0,
Closed = 1 << 0,
Enter = 1 << 1,
Leave = 1 << 2,
}
export type TransitionData = {
closed?: boolean
enter?: boolean
leave?: boolean
transition?: boolean
}
export function useTransitionData(
enabled: boolean,
elementRef: MutableRefObject<HTMLElement | null>,
show: boolean
): [visible: boolean, data: TransitionData] {
let [visible, setVisible] = useState(show)
let { hasFlag, addFlag, removeFlag } = useFlags(
visible ? TransitionState.Closed : TransitionState.None
)
let inFlight = useRef(false)
let cancelledRef = useRef(false)
let d = useDisposables()
useIsoMorphicEffect(
function retry() {
if (!enabled) return
if (show) {
setVisible(true)
}
let node = elementRef.current
if (!node) {
// Retry if the DOM node isn't available yet
if (show) {
addFlag(TransitionState.Enter | TransitionState.Closed)
return d.nextFrame(() => retry())
}
return
}
return transition(node, {
inFlight,
prepare() {
if (cancelledRef.current) {
// Cancelled a cancellation, we're back to the original state.
cancelledRef.current = false
} else {
// If we were already in-flight, then we want to cancel the current
// transition.
cancelledRef.current = inFlight.current
}
inFlight.current = true
if (cancelledRef.current) return
if (show) {
addFlag(TransitionState.Enter | TransitionState.Closed)
removeFlag(TransitionState.Leave)
} else {
addFlag(TransitionState.Leave)
removeFlag(TransitionState.Enter)
}
},
run() {
if (cancelledRef.current) {
// If we cancelled a transition, then the `show` state is going to
// be inverted already, but that doesn't mean we have to go to that
// new state.
//
// What we actually want is to revert to the "idle" state (the
// stable state where an `Enter` transitions to, and a `Leave`
// transitions from.)
//
// Because of this, it might look like we are swapping the flags in
// the following branches, but that's not the case.
if (show) {
removeFlag(TransitionState.Enter | TransitionState.Closed)
addFlag(TransitionState.Leave)
} else {
removeFlag(TransitionState.Leave)
addFlag(TransitionState.Enter | TransitionState.Closed)
}
} else {
if (show) {
removeFlag(TransitionState.Closed)
} else {
addFlag(TransitionState.Closed)
}
}
},
done() {
if (cancelledRef.current) {
if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) {
return
}
}
inFlight.current = false
removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed)
if (!show) {
setVisible(false)
}
},
})
},
[enabled, show, elementRef, d]
)
if (!enabled) {
return [
show,
{
closed: undefined,
enter: undefined,
leave: undefined,
transition: undefined,
},
] as const
}
return [
visible,
{
closed: hasFlag(TransitionState.Closed),
enter: hasFlag(TransitionState.Enter),
leave: hasFlag(TransitionState.Leave),
transition: hasFlag(TransitionState.Enter) || hasFlag(TransitionState.Leave),
},
] as const
}
function transition(
node: HTMLElement,
{
prepare,
run,
done,
inFlight,
}: {
prepare: () => void
run: () => void
done: () => void
inFlight: MutableRefObject<boolean>
}
) {
let d = disposables()
// Prepare the transitions by ensuring that all the "before" classes are
// applied and flushed to the DOM.
prepareTransition(node, {
prepare,
inFlight,
})
// 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, done))
// Initiate the transition by applying the new classes.
run()
})
return d.dispose
}
@@ -1,66 +1,314 @@
import { useRef, type MutableRefObject } from 'react'
import { transition } from '../components/transition/utils/transition'
import { useRef, useState, type MutableRefObject } from 'react'
import { disposables } from '../utils/disposables'
import { once } from '../utils/once'
import { useDisposables } from './use-disposables'
import { useIsMounted } from './use-is-mounted'
import { useFlags } from './use-flags'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
interface TransitionArgs {
container: MutableRefObject<HTMLElement | null>
classes: MutableRefObject<{
base: string[]
/**
* ```
*
* Closed Closed
*
*
* Frame Frame Frame Frame Frame Frame
*
*
* Enter Leave
*
*
* Transition Transition
*
*
* Applied when `Enter` or `Leave` is applied.
* ```
*/
enum TransitionState {
None = 0,
enter: string[]
enterFrom: string[]
enterTo: string[]
Closed = 1 << 0,
leave: string[]
leaveFrom: string[]
leaveTo: string[]
entered: string[]
}>
direction: 'enter' | 'leave' | 'idle'
onStart: MutableRefObject<(direction: TransitionArgs['direction']) => void>
onStop: MutableRefObject<(direction: TransitionArgs['direction']) => void>
Enter = 1 << 1,
Leave = 1 << 2,
}
export function useTransition({ container, direction, classes, onStart, onStop }: TransitionArgs) {
let mounted = useIsMounted()
export type TransitionData = {
closed?: boolean
enter?: boolean
leave?: boolean
transition?: boolean
}
export function useTransition(
enabled: boolean,
elementRef: MutableRefObject<HTMLElement | null>,
show: boolean,
events?: {
start?(show: boolean): void
end?(show: boolean): void
}
): [visible: boolean, data: TransitionData] {
let [visible, setVisible] = useState(show)
let { hasFlag, addFlag, removeFlag } = useFlags(
enabled && visible ? TransitionState.Enter | TransitionState.Closed : TransitionState.None
)
let inFlight = useRef(false)
let cancelledRef = useRef(false)
let d = useDisposables()
// Track whether the transition is in flight or not. This will help us for
// cancelling mid-transition because in that case we don't have to force
// clearing existing transitions. See: `prepareTransition` in the `transition`
// file.
let inFlight = useRef(false)
useIsoMorphicEffect(
function retry() {
if (!enabled) return
useIsoMorphicEffect(() => {
if (direction === 'idle') return // We don't need to transition
if (!mounted.current) return
if (show) {
setVisible(true)
}
onStart.current(direction)
let node = elementRef.current
if (!node) {
// Retry if the DOM node isn't available yet
if (show) {
addFlag(TransitionState.Enter | TransitionState.Closed)
return d.nextFrame(() => retry())
}
return
}
let node = container.current
if (!node) {
// No node, so let's skip the transition and call the `onStop` callback
// immediately because there is no transition to wait for anyway.
onStop.current(direction)
}
events?.start?.(show)
// We do have a node, let's transition it!
else {
d.add(
transition(node, {
direction,
classes: classes.current,
inFlight,
done() {
onStop.current(direction)
},
})
)
}
return transition(node, {
inFlight,
prepare() {
if (cancelledRef.current) {
// Cancelled a cancellation, we're back to the original state.
cancelledRef.current = false
} else {
// If we were already in-flight, then we want to cancel the current
// transition.
cancelledRef.current = inFlight.current
}
return d.dispose
}, [direction])
inFlight.current = true
if (cancelledRef.current) return
if (show) {
addFlag(TransitionState.Enter | TransitionState.Closed)
removeFlag(TransitionState.Leave)
} else {
addFlag(TransitionState.Leave)
removeFlag(TransitionState.Enter)
}
},
run() {
if (cancelledRef.current) {
// If we cancelled a transition, then the `show` state is going to
// be inverted already, but that doesn't mean we have to go to that
// new state.
//
// What we actually want is to revert to the "idle" state (the
// stable state where an `Enter` transitions to, and a `Leave`
// transitions from.)
//
// Because of this, it might look like we are swapping the flags in
// the following branches, but that's not the case.
if (show) {
removeFlag(TransitionState.Enter | TransitionState.Closed)
addFlag(TransitionState.Leave)
} else {
removeFlag(TransitionState.Leave)
addFlag(TransitionState.Enter | TransitionState.Closed)
}
} else {
if (show) {
removeFlag(TransitionState.Closed)
} else {
addFlag(TransitionState.Closed)
}
}
},
done() {
if (cancelledRef.current) {
if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) {
return
}
}
inFlight.current = false
removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed)
if (!show) {
setVisible(false)
}
events?.end?.(show)
},
})
},
[enabled, show, elementRef, d]
)
if (!enabled) {
return [
show,
{
closed: undefined,
enter: undefined,
leave: undefined,
transition: undefined,
},
] as const
}
return [
visible,
{
closed: hasFlag(TransitionState.Closed),
enter: hasFlag(TransitionState.Enter),
leave: hasFlag(TransitionState.Leave),
transition: hasFlag(TransitionState.Enter) || hasFlag(TransitionState.Leave),
},
] as const
}
function transition(
node: HTMLElement,
{
prepare,
run,
done,
inFlight,
}: {
prepare: () => void
run: () => void
done: () => void
inFlight: MutableRefObject<boolean>
}
) {
let d = disposables()
// Prepare the transitions by ensuring that all the "before" classes are
// applied and flushed to the DOM.
prepareTransition(node, {
prepare,
inFlight,
})
// 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, done))
// Initiate the transition by applying the new classes.
run()
})
return d.dispose
}
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
}
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
}
@@ -0,0 +1,55 @@
import { Transition } from '@headlessui/react'
import clsx from 'clsx'
import { useState } from 'react'
export default function Example() {
const [open, setOpen] = useState(false)
return (
<div className="grid min-h-full place-content-center">
<div className="flex flex-col">
<button onClick={() => setOpen((open) => !open)}>Toggle transition</button>
<div className="flex h-20 w-80">
<Before open={open} />
<After open={open} />
</div>
</div>
</div>
)
}
function Before({ open }: { open: boolean }) {
return (
<Transition
show={open}
enter="transition ease-in-out duration-300"
enterFrom="opacity-0 -translate-x-full"
enterTo="opacity-100 translate-x-0"
leave="transition ease-in-out duration-300"
leaveFrom="opacity-100 translate-x-0"
leaveTo="opacity-0 translate-x-full"
>
<div className="h-20 w-48 border bg-blue-500 p-2 text-white">Using specific props</div>
</Transition>
)
}
function After({ open }: { open: boolean }) {
return (
<Transition show={open}>
<div
className={clsx([
// Defaults
'h-20 w-48 border bg-blue-500 p-2 text-white transition ease-in-out',
// Closed
'data-[closed]:opacity-0',
// Entering
'data-[enter]:data-[closed]:-translate-x-full data-[enter]:duration-300',
// Leaving
'data-[leave]:data-[closed]:translate-x-full data-[leave]:duration-300',
])}
>
Using data attributes
</div>
</Transition>
)
}