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:
@@ -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
|
||||
|
||||
+25
-141
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user