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:
@@ -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,
|
||||
|
||||
+139
-19
@@ -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 │
|
||||
* └─────┘ │ └────────────┘
|
||||
* ┌─────┐┌─────┐┌─────┐│┌─────┐┌─────┐┌─────┐
|
||||
* │Frame││Frame││Frame│││Frame││Frame││Frame│
|
||||
* └─────┘└─────┘└─────┘│└─────┘└─────┘└─────┘
|
||||
* ┌───────────────────┐│┌───────────────────┐
|
||||
* │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
|
||||
}
|
||||
Reference in New Issue
Block a user