Introduce CSS based transitions (#3273)

* simplify `useFlags`

* add new `useTransitionData` hook

* use new `useTransitionData` hook

* add ability to cancel transitions mid-transition

* handle cancellations in both directions properly

* re-use existing `prepareTransition`

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

* update tests to reflect added data attributes

* update changelog

* only call `getAnimations` if available

This has been around since 2020, but JSDOM doesn't know about this yet,
so tests using JSDOM will fail otherwise.
This commit is contained in:
Robin Malfait
2024-06-11 17:53:21 +02:00
committed by GitHub
parent 03c22b42b6
commit 6b6c259010
12 changed files with 491 additions and 133 deletions
+1
View File
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add ability to render multiple `<Dialog />` components at once (without nesting them) ([#3242](https://github.com/tailwindlabs/headlessui/pull/3242))
- Add CSS based transitions using `data-*` attributes ([#3273](https://github.com/tailwindlabs/headlessui/pull/3273))
### Fixed
@@ -41,6 +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 { useTreeWalker } from '../../hooks/use-tree-walker'
import { useWatch } from '../../hooks/use-watch'
import { useDisabled } from '../../internal/disabled'
@@ -446,6 +447,7 @@ type _Actions = ReturnType<typeof useActions>
let VirtualContext = createContext<Virtualizer<any, any> | null>(null)
function VirtualProvider(props: {
slot: OptionsRenderPropArg
children: (data: { option: unknown; open: boolean }) => React.ReactElement
}) {
let data = useData('VirtualProvider')
@@ -523,8 +525,8 @@ function VirtualProvider(props: {
<Fragment key={item.key}>
{React.cloneElement(
props.children?.({
...props.slot,
option: options[item.index],
open: data.comboboxState === ComboboxState.Open,
}),
{
key: `${baseKey}-${item.key}`,
@@ -1561,7 +1563,7 @@ let DEFAULT_OPTIONS_TAG = 'div' as const
type OptionsRenderPropArg = {
open: boolean
option: unknown
}
} & TransitionData
type OptionsPropsWeControl = 'aria-labelledby' | 'aria-multiselectable' | 'role' | 'tabIndex'
let OptionsRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -1575,6 +1577,7 @@ export type ComboboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTIO
anchor?: AnchorProps
portal?: boolean
modal?: boolean
transition?: boolean
}
>
@@ -1589,6 +1592,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
anchor: rawAnchor,
portal = false,
modal = true,
transition = false,
...theirProps
} = props
let data = useData('Combobox.Options')
@@ -1606,13 +1610,13 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
let ownerDocument = useOwnerDocument(data.optionsRef)
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return data.comboboxState === ComboboxState.Open
})()
let [visible, transitionData] = useTransitionData(
transition,
data.optionsRef,
usesOpenClosedState !== null
? (usesOpenClosedState & State.Open) === State.Open
: data.comboboxState === ComboboxState.Open
)
// Ensure we close the combobox as soon as the input becomes hidden
useOnDisappear(visible, data.inputRef, actions.closeCombobox)
@@ -1660,8 +1664,9 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
return {
open: data.comboboxState === ComboboxState.Open,
option: undefined,
...transitionData,
} satisfies OptionsRenderPropArg
}, [data])
}, [data.comboboxState, transitionData])
// When the user scrolls **using the mouse** (so scroll event isn't appropriate)
// we want to make sure that the current activation trigger is set to pointer.
@@ -1706,7 +1711,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
if (data.virtual && visible) {
Object.assign(theirProps, {
// @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
children: <VirtualProvider>{theirProps.children}</VirtualProvider>,
children: <VirtualProvider slot={slot}>{theirProps.children}</VirtualProvider>,
})
}
@@ -24,6 +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 { CloseProvider } from '../../internal/close-provider'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import type { Props } from '../../types'
@@ -419,7 +420,7 @@ let DEFAULT_PANEL_TAG = 'div' as const
type PanelRenderPropArg = {
open: boolean
close: (focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => void
}
} & TransitionData
type DisclosurePanelPropsWeControl = never
let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -428,7 +429,7 @@ export type DisclosurePanelProps<TTag extends ElementType = typeof DEFAULT_PANEL
TTag,
PanelRenderPropArg,
DisclosurePanelPropsWeControl,
PropsForFeatures<typeof PanelRenderFeatures>
{ transition?: boolean } & PropsForFeatures<typeof PanelRenderFeatures>
>
function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
@@ -436,7 +437,11 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
ref: Ref<HTMLElement>
) {
let internalId = useId()
let { id = `headlessui-disclosure-panel-${internalId}`, ...theirProps } = props
let {
id = `headlessui-disclosure-panel-${internalId}`,
transition = false,
...theirProps
} = props
let [state, dispatch] = useDisclosureContext('Disclosure.Panel')
let { close } = useDisclosureAPIContext('Disclosure.Panel')
let mergeRefs = useMergeRefsFn()
@@ -453,20 +458,21 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
}, [id, dispatch])
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return state.disclosureState === DisclosureStates.Open
})()
let [visible, transitionData] = useTransitionData(
transition,
state.panelRef,
usesOpenClosedState !== null
? (usesOpenClosedState & State.Open) === State.Open
: state.disclosureState === DisclosureStates.Open
)
let slot = useMemo(() => {
return {
open: state.disclosureState === DisclosureStates.Open,
close,
...transitionData,
} satisfies PanelRenderPropArg
}, [state, close])
}, [state.disclosureState, close, transitionData])
let ourProps = {
ref: panelRef,
@@ -42,6 +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 { useDisabled } from '../../internal/disabled'
import {
FloatingProvider,
@@ -863,7 +864,7 @@ let SelectedOptionContext = createContext(false)
let DEFAULT_OPTIONS_TAG = 'div' as const
type OptionsRenderPropArg = {
open: boolean
}
} & TransitionData
type OptionsPropsWeControl =
| 'aria-activedescendant'
| 'aria-labelledby'
@@ -882,6 +883,7 @@ export type ListboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTION
anchor?: AnchorPropsWithSelection
portal?: boolean
modal?: boolean
transition?: boolean
} & PropsForFeatures<typeof OptionsRenderFeatures>
>
@@ -895,6 +897,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
anchor: rawAnchor,
portal = false,
modal = true,
transition = false,
...theirProps
} = props
let anchor = useResolvedAnchor(rawAnchor)
@@ -910,13 +913,13 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
let ownerDocument = useOwnerDocument(data.optionsRef)
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return data.listboxState === ListboxStates.Open
})()
let [visible, transitionData] = useTransitionData(
transition,
data.optionsRef,
usesOpenClosedState !== null
? (usesOpenClosedState & State.Open) === State.Open
: data.listboxState === ListboxStates.Open
)
// Ensure we close the listbox as soon as the button becomes hidden
useOnDisappear(visible, data.buttonRef, actions.closeListbox)
@@ -1073,10 +1076,12 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
})
let labelledby = useComputed(() => data.buttonRef.current?.id, [data.buttonRef.current])
let slot = useMemo(
() => ({ open: data.listboxState === ListboxStates.Open }) satisfies OptionsRenderPropArg,
[data]
)
let slot = useMemo(() => {
return {
open: data.listboxState === ListboxStates.Open,
...transitionData,
} satisfies OptionsRenderPropArg
}, [data.listboxState, transitionData])
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
id,
@@ -37,6 +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 { useTreeWalker } from '../../hooks/use-tree-walker'
import {
FloatingProvider,
@@ -564,7 +565,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
let DEFAULT_ITEMS_TAG = 'div' as const
type ItemsRenderPropArg = {
open: boolean
}
} & TransitionData
type ItemsPropsWeControl = 'aria-activedescendant' | 'aria-labelledby' | 'role' | 'tabIndex'
let ItemsRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -577,6 +578,7 @@ export type MenuItemsProps<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>
anchor?: AnchorProps
portal?: boolean
modal?: boolean
transition?: boolean
// ItemsRenderFeatures
static?: boolean
@@ -594,6 +596,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
anchor: rawAnchor,
portal = false,
modal = true,
transition = false,
...theirProps
} = props
let anchor = useResolvedAnchor(rawAnchor)
@@ -608,16 +611,14 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
portal = true
}
let searchDisposables = useDisposables()
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return state.menuState === MenuStates.Open
})()
let [visible, transitionData] = useTransitionData(
transition,
state.itemsRef,
usesOpenClosedState !== null
? (usesOpenClosedState & State.Open) === State.Open
: state.menuState === MenuStates.Open
)
// Ensure we close the menu as soon as the button becomes hidden
useOnDisappear(visible, state.buttonRef, () => {
@@ -671,6 +672,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
},
})
let searchDisposables = useDisposables()
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
searchDisposables.dispose()
@@ -755,10 +757,12 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
}
})
let slot = useMemo(
() => ({ open: state.menuState === MenuStates.Open }) satisfies ItemsRenderPropArg,
[state]
)
let slot = useMemo(() => {
return {
open: state.menuState === MenuStates.Open,
...transitionData,
} satisfies ItemsRenderPropArg
}, [state, transitionData])
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
'aria-activedescendant':
@@ -36,6 +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 { CloseProvider } from '../../internal/close-provider'
import {
FloatingProvider,
@@ -726,7 +727,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
let DEFAULT_OVERLAY_TAG = 'div' as const
type OverlayRenderPropArg = {
open: boolean
}
} & TransitionData
type OverlayPropsWeControl = 'aria-hidden'
let OverlayRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -735,7 +736,7 @@ export type PopoverOverlayProps<TTag extends ElementType = typeof DEFAULT_OVERLA
TTag,
OverlayRenderPropArg,
OverlayPropsWeControl,
PropsForFeatures<typeof OverlayRenderFeatures>
{ transition?: boolean } & PropsForFeatures<typeof OverlayRenderFeatures>
>
function OverlayFn<TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG>(
@@ -743,28 +744,31 @@ function OverlayFn<TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG>(
ref: Ref<HTMLElement>
) {
let internalId = useId()
let { id = `headlessui-popover-overlay-${internalId}`, ...theirProps } = props
let { id = `headlessui-popover-overlay-${internalId}`, transition = false, ...theirProps } = props
let [{ popoverState }, dispatch] = usePopoverContext('Popover.Overlay')
let overlayRef = useSyncRefs(ref)
let internalOverlayRef = useRef<HTMLElement | null>(null)
let overlayRef = useSyncRefs(ref, internalOverlayRef)
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return popoverState === PopoverStates.Open
})()
let [visible, transitionData] = useTransitionData(
transition,
internalOverlayRef,
usesOpenClosedState !== null
? (usesOpenClosedState & State.Open) === State.Open
: popoverState === PopoverStates.Open
)
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
dispatch({ type: ActionTypes.ClosePopover })
})
let slot = useMemo(
() => ({ open: popoverState === PopoverStates.Open }) satisfies OverlayRenderPropArg,
[popoverState]
)
let slot = useMemo(() => {
return {
open: popoverState === PopoverStates.Open,
...transitionData,
} satisfies OverlayRenderPropArg
}, [popoverState, transitionData])
let ourProps = {
ref: overlayRef,
@@ -790,7 +794,7 @@ let DEFAULT_PANEL_TAG = 'div' as const
type PanelRenderPropArg = {
open: boolean
close: (focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => void
}
} & TransitionData
let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -805,6 +809,7 @@ export type PopoverPanelProps<TTag extends ElementType = typeof DEFAULT_PANEL_TA
anchor?: AnchorProps
portal?: boolean
modal?: boolean
transition?: boolean
// ItemsRenderFeatures
static?: boolean
@@ -823,6 +828,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
anchor: rawAnchor,
portal = false,
modal = false,
transition = false,
...theirProps
} = props
@@ -856,13 +862,13 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
}, [id, dispatch])
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return state.popoverState === PopoverStates.Open
})()
let [visible, transitionData] = useTransitionData(
transition,
internalPanelRef,
usesOpenClosedState !== null
? (usesOpenClosedState & State.Open) === State.Open
: state.popoverState === PopoverStates.Open
)
// Ensure we close the popover as soon as the button becomes hidden
useOnDisappear(visible, state.button, () => {
@@ -914,10 +920,13 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
focusIn(internalPanelRef.current, Focus.First)
}, [state.__demoMode, focus, internalPanelRef, state.popoverState])
let slot = useMemo(
() => ({ open: state.popoverState === PopoverStates.Open, close }) satisfies PanelRenderPropArg,
[state, close]
)
let slot = useMemo(() => {
return {
open: state.popoverState === PopoverStates.Open,
close,
...transitionData,
} satisfies PanelRenderPropArg
}, [state.popoverState, close, transitionData])
let ourProps: Record<string, any> = mergeProps(anchor ? getFloatingPanelProps() : {}, {
ref: panelRef,
@@ -4,11 +4,29 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
<div
class="My Page"
>
<article>
<aside>
<article
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
<aside
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Sidebar
</aside>
<section>
<section
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Content
</section>
</article>
@@ -19,11 +37,29 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
<div
class="My Page"
>
<div>
<aside>
<div
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
<aside
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Sidebar
</aside>
<section>
<section
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Content
</section>
</div>
@@ -34,11 +70,29 @@ exports[`Setup API nested should be possible to nest transition components 1`] =
<div
class="My Page"
>
<div>
<div>
<div
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
<div
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Sidebar
</div>
<div>
<div
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Content
</div>
</div>
@@ -49,11 +103,26 @@ exports[`Setup API nested should be possible to use render props on the Transiti
<div
class="My Page"
>
<article>
<aside>
<article
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
>
<aside
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
>
Sidebar
</aside>
<section>
<section
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
>
Content
</section>
</article>
@@ -64,11 +133,27 @@ exports[`Setup API nested should be possible to use render props on the Transiti
<div
class="My Page"
>
<div>
<aside>
<div
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
<aside
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
>
Sidebar
</aside>
<section>
<section
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
>
Content
</section>
</div>
@@ -82,13 +167,24 @@ 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>
<span
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Children
</span>
`;
exports[`Setup API shallow should be possible to use a render prop 1`] = `
<span>
<span
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
>
Children
</span>
`;
@@ -96,7 +192,12 @@ 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-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
id="root"
style=""
>
Children
</div>
@@ -105,14 +206,25 @@ 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-enter=""
data-from=""
data-headlessui-state="from 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>
<a
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Children
</a>
`;
@@ -122,7 +234,13 @@ 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>
<div
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Children
</div>
`;
@@ -130,6 +248,8 @@ exports[`Setup API transition classes should be possible to passthrough the tran
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-from=""
data-headlessui-state="from"
style=""
>
Children
@@ -358,6 +358,11 @@ describe('Setup API', () => {
<div
class="foo1
foo2"
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Children
</div>
@@ -370,18 +375,22 @@ describe('Setup API', () => {
// The `foo1\nfoo2` should be gone
// I think this is a quirk of JSDOM
expect(container.firstChild).toMatchInlineSnapshot(`
<div>
<button>
toggle
</button>
<div
class="foo1
foo2 foo1 foo2 leave"
style=""
>
Children
</div>
</div>
<div>
<button>
toggle
</button>
<div
class="foo1
foo2 foo1 foo2 leave"
data-enter=""
data-from=""
data-headlessui-state="from enter transition"
data-transition=""
style=""
>
Children
</div>
</div>
`)
})
@@ -21,6 +21,7 @@ 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'
@@ -501,6 +502,8 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
if (theirProps.className === '') delete theirProps.className
}
let [, slot] = useTransitionData(ready, container, show)
return (
<NestingContext.Provider value={nesting}>
<OpenClosedProvider
@@ -514,6 +517,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_TRANSITION_CHILD_TAG,
features: TransitionChildRenderFeatures,
visible: state === TreeStates.Visible,
@@ -11,7 +11,7 @@ function removeClasses(node: HTMLElement, ...classes: string[]) {
node && classes.length > 0 && node.classList.remove(...classes)
}
function waitForTransition(node: HTMLElement, _done: () => void) {
export function waitForTransition(node: HTMLElement, _done: () => void) {
let done = once(_done)
let d = disposables()
@@ -184,7 +184,7 @@ export function transition(
return d.dispose
}
function prepareTransition(
export function prepareTransition(
node: HTMLElement,
{ inFlight, prepare }: { inFlight?: MutableRefObject<boolean>; prepare: () => void }
) {
@@ -1,32 +1,14 @@
import { useCallback, useState } from 'react'
import { useIsMounted } from './use-is-mounted'
export function useFlags(initialFlags = 0) {
let [flags, setFlags] = useState(initialFlags)
let mounted = useIsMounted()
let addFlag = useCallback(
(flag: number) => {
if (!mounted.current) return
setFlags((flags) => flags | flag)
},
[flags, mounted]
)
let hasFlag = useCallback((flag: number) => Boolean(flags & flag), [flags])
let removeFlag = useCallback(
(flag: number) => {
if (!mounted.current) return
setFlags((flags) => flags & ~flag)
},
[setFlags, mounted]
)
let toggleFlag = useCallback(
(flag: number) => {
if (!mounted.current) return
setFlags((flags) => flags ^ flag)
},
[setFlags]
)
let setFlag = useCallback((flag: number) => setFlags(flag), [flags])
return { flags, addFlag, hasFlag, removeFlag, toggleFlag }
let addFlag = useCallback((flag: number) => setFlags((flags) => flags | flag), [flags])
let hasFlag = useCallback((flag: number) => (flags & flag) === flag, [flags])
let removeFlag = useCallback((flag: number) => setFlags((flags) => flags & ~flag), [setFlags])
let toggleFlag = useCallback((flag: number) => setFlags((flags) => flags ^ flag), [setFlags])
return { flags, setFlag, addFlag, hasFlag, removeFlag, toggleFlag }
}
@@ -0,0 +1,213 @@
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'
/**
* ```
*
* From From
*
*
* FrameFrameFrameFrameFrameFrame
*
*
* Enter Exit
*
*
* Transition Transition
*
*
* Applied when `Enter` or `Exit` is applied.
* ```
*/
enum TransitionState {
None = 0,
From = 1 << 0,
Enter = 1 << 1,
Exit = 1 << 2,
}
export type TransitionData = {
from?: boolean
enter?: boolean
exit?: 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.From : 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.From)
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.From)
removeFlag(TransitionState.Exit)
} else {
addFlag(TransitionState.Exit)
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 an `Exit`
// 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.From)
addFlag(TransitionState.Exit)
} else {
removeFlag(TransitionState.Exit)
addFlag(TransitionState.Enter | TransitionState.From)
}
} else {
if (show) {
removeFlag(TransitionState.From)
} else {
addFlag(TransitionState.From)
}
}
},
done() {
if (cancelledRef.current) {
if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) {
return
}
}
inFlight.current = false
removeFlag(TransitionState.Enter | TransitionState.Exit | TransitionState.From)
if (!show) {
setVisible(false)
}
},
})
},
[enabled, show, elementRef, d]
)
if (!enabled) {
return [
show,
{
from: undefined,
enter: undefined,
exit: undefined,
transition: undefined,
},
] as const
}
return [
visible,
{
from: hasFlag(TransitionState.From),
enter: hasFlag(TransitionState.Enter),
exit: hasFlag(TransitionState.Exit),
transition: hasFlag(TransitionState.Enter) || hasFlag(TransitionState.Exit),
},
] 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
}