Skip to content

Commit 6b6c259

Browse files
authored
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.
1 parent 03c22b4 commit 6b6c259

File tree

12 files changed

+491
-133
lines changed

12 files changed

+491
-133
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Add ability to render multiple `<Dialog />` components at once (without nesting them) ([#3242](https://github.com/tailwindlabs/headlessui/pull/3242))
13+
- Add CSS based transitions using `data-*` attributes ([#3273](https://github.com/tailwindlabs/headlessui/pull/3273))
1314

1415
### Fixed
1516

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
4141
import { useScrollLock } from '../../hooks/use-scroll-lock'
4242
import { useSyncRefs } from '../../hooks/use-sync-refs'
4343
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
44+
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
4445
import { useTreeWalker } from '../../hooks/use-tree-walker'
4546
import { useWatch } from '../../hooks/use-watch'
4647
import { useDisabled } from '../../internal/disabled'
@@ -446,6 +447,7 @@ type _Actions = ReturnType<typeof useActions>
446447
let VirtualContext = createContext<Virtualizer<any, any> | null>(null)
447448

448449
function VirtualProvider(props: {
450+
slot: OptionsRenderPropArg
449451
children: (data: { option: unknown; open: boolean }) => React.ReactElement
450452
}) {
451453
let data = useData('VirtualProvider')
@@ -523,8 +525,8 @@ function VirtualProvider(props: {
523525
<Fragment key={item.key}>
524526
{React.cloneElement(
525527
props.children?.({
528+
...props.slot,
526529
option: options[item.index],
527-
open: data.comboboxState === ComboboxState.Open,
528530
}),
529531
{
530532
key: `${baseKey}-${item.key}`,
@@ -1561,7 +1563,7 @@ let DEFAULT_OPTIONS_TAG = 'div' as const
15611563
type OptionsRenderPropArg = {
15621564
open: boolean
15631565
option: unknown
1564-
}
1566+
} & TransitionData
15651567
type OptionsPropsWeControl = 'aria-labelledby' | 'aria-multiselectable' | 'role' | 'tabIndex'
15661568

15671569
let OptionsRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -1575,6 +1577,7 @@ export type ComboboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTIO
15751577
anchor?: AnchorProps
15761578
portal?: boolean
15771579
modal?: boolean
1580+
transition?: boolean
15781581
}
15791582
>
15801583

@@ -1589,6 +1592,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
15891592
anchor: rawAnchor,
15901593
portal = false,
15911594
modal = true,
1595+
transition = false,
15921596
...theirProps
15931597
} = props
15941598
let data = useData('Combobox.Options')
@@ -1606,13 +1610,13 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16061610
let ownerDocument = useOwnerDocument(data.optionsRef)
16071611

16081612
let usesOpenClosedState = useOpenClosed()
1609-
let visible = (() => {
1610-
if (usesOpenClosedState !== null) {
1611-
return (usesOpenClosedState & State.Open) === State.Open
1612-
}
1613-
1614-
return data.comboboxState === ComboboxState.Open
1615-
})()
1613+
let [visible, transitionData] = useTransitionData(
1614+
transition,
1615+
data.optionsRef,
1616+
usesOpenClosedState !== null
1617+
? (usesOpenClosedState & State.Open) === State.Open
1618+
: data.comboboxState === ComboboxState.Open
1619+
)
16161620

16171621
// Ensure we close the combobox as soon as the input becomes hidden
16181622
useOnDisappear(visible, data.inputRef, actions.closeCombobox)
@@ -1660,8 +1664,9 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16601664
return {
16611665
open: data.comboboxState === ComboboxState.Open,
16621666
option: undefined,
1667+
...transitionData,
16631668
} satisfies OptionsRenderPropArg
1664-
}, [data])
1669+
}, [data.comboboxState, transitionData])
16651670

16661671
// When the user scrolls **using the mouse** (so scroll event isn't appropriate)
16671672
// 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>(
17061711
if (data.virtual && visible) {
17071712
Object.assign(theirProps, {
17081713
// @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
1709-
children: <VirtualProvider>{theirProps.children}</VirtualProvider>,
1714+
children: <VirtualProvider slot={slot}>{theirProps.children}</VirtualProvider>,
17101715
})
17111716
}
17121717

packages/@headlessui-react/src/components/disclosure/disclosure.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useEvent } from '../../hooks/use-event'
2424
import { useId } from '../../hooks/use-id'
2525
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2626
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
27+
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
2728
import { CloseProvider } from '../../internal/close-provider'
2829
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
2930
import type { Props } from '../../types'
@@ -419,7 +420,7 @@ let DEFAULT_PANEL_TAG = 'div' as const
419420
type PanelRenderPropArg = {
420421
open: boolean
421422
close: (focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => void
422-
}
423+
} & TransitionData
423424
type DisclosurePanelPropsWeControl = never
424425

425426
let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -428,15 +429,19 @@ export type DisclosurePanelProps<TTag extends ElementType = typeof DEFAULT_PANEL
428429
TTag,
429430
PanelRenderPropArg,
430431
DisclosurePanelPropsWeControl,
431-
PropsForFeatures<typeof PanelRenderFeatures>
432+
{ transition?: boolean } & PropsForFeatures<typeof PanelRenderFeatures>
432433
>
433434

434435
function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
435436
props: DisclosurePanelProps<TTag>,
436437
ref: Ref<HTMLElement>
437438
) {
438439
let internalId = useId()
439-
let { id = `headlessui-disclosure-panel-${internalId}`, ...theirProps } = props
440+
let {
441+
id = `headlessui-disclosure-panel-${internalId}`,
442+
transition = false,
443+
...theirProps
444+
} = props
440445
let [state, dispatch] = useDisclosureContext('Disclosure.Panel')
441446
let { close } = useDisclosureAPIContext('Disclosure.Panel')
442447
let mergeRefs = useMergeRefsFn()
@@ -453,20 +458,21 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
453458
}, [id, dispatch])
454459

455460
let usesOpenClosedState = useOpenClosed()
456-
let visible = (() => {
457-
if (usesOpenClosedState !== null) {
458-
return (usesOpenClosedState & State.Open) === State.Open
459-
}
460-
461-
return state.disclosureState === DisclosureStates.Open
462-
})()
461+
let [visible, transitionData] = useTransitionData(
462+
transition,
463+
state.panelRef,
464+
usesOpenClosedState !== null
465+
? (usesOpenClosedState & State.Open) === State.Open
466+
: state.disclosureState === DisclosureStates.Open
467+
)
463468

464469
let slot = useMemo(() => {
465470
return {
466471
open: state.disclosureState === DisclosureStates.Open,
467472
close,
473+
...transitionData,
468474
} satisfies PanelRenderPropArg
469-
}, [state, close])
475+
}, [state.disclosureState, close, transitionData])
470476

471477
let ourProps = {
472478
ref: panelRef,

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { useScrollLock } from '../../hooks/use-scroll-lock'
4242
import { useSyncRefs } from '../../hooks/use-sync-refs'
4343
import { useTextValue } from '../../hooks/use-text-value'
4444
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
45+
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
4546
import { useDisabled } from '../../internal/disabled'
4647
import {
4748
FloatingProvider,
@@ -863,7 +864,7 @@ let SelectedOptionContext = createContext(false)
863864
let DEFAULT_OPTIONS_TAG = 'div' as const
864865
type OptionsRenderPropArg = {
865866
open: boolean
866-
}
867+
} & TransitionData
867868
type OptionsPropsWeControl =
868869
| 'aria-activedescendant'
869870
| 'aria-labelledby'
@@ -882,6 +883,7 @@ export type ListboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTION
882883
anchor?: AnchorPropsWithSelection
883884
portal?: boolean
884885
modal?: boolean
886+
transition?: boolean
885887
} & PropsForFeatures<typeof OptionsRenderFeatures>
886888
>
887889

@@ -895,6 +897,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
895897
anchor: rawAnchor,
896898
portal = false,
897899
modal = true,
900+
transition = false,
898901
...theirProps
899902
} = props
900903
let anchor = useResolvedAnchor(rawAnchor)
@@ -910,13 +913,13 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
910913
let ownerDocument = useOwnerDocument(data.optionsRef)
911914

912915
let usesOpenClosedState = useOpenClosed()
913-
let visible = (() => {
914-
if (usesOpenClosedState !== null) {
915-
return (usesOpenClosedState & State.Open) === State.Open
916-
}
917-
918-
return data.listboxState === ListboxStates.Open
919-
})()
916+
let [visible, transitionData] = useTransitionData(
917+
transition,
918+
data.optionsRef,
919+
usesOpenClosedState !== null
920+
? (usesOpenClosedState & State.Open) === State.Open
921+
: data.listboxState === ListboxStates.Open
922+
)
920923

921924
// Ensure we close the listbox as soon as the button becomes hidden
922925
useOnDisappear(visible, data.buttonRef, actions.closeListbox)
@@ -1073,10 +1076,12 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
10731076
})
10741077

10751078
let labelledby = useComputed(() => data.buttonRef.current?.id, [data.buttonRef.current])
1076-
let slot = useMemo(
1077-
() => ({ open: data.listboxState === ListboxStates.Open }) satisfies OptionsRenderPropArg,
1078-
[data]
1079-
)
1079+
let slot = useMemo(() => {
1080+
return {
1081+
open: data.listboxState === ListboxStates.Open,
1082+
...transitionData,
1083+
} satisfies OptionsRenderPropArg
1084+
}, [data.listboxState, transitionData])
10801085

10811086
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
10821087
id,

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { useScrollLock } from '../../hooks/use-scroll-lock'
3737
import { useSyncRefs } from '../../hooks/use-sync-refs'
3838
import { useTextValue } from '../../hooks/use-text-value'
3939
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
40+
import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
4041
import { useTreeWalker } from '../../hooks/use-tree-walker'
4142
import {
4243
FloatingProvider,
@@ -564,7 +565,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
564565
let DEFAULT_ITEMS_TAG = 'div' as const
565566
type ItemsRenderPropArg = {
566567
open: boolean
567-
}
568+
} & TransitionData
568569
type ItemsPropsWeControl = 'aria-activedescendant' | 'aria-labelledby' | 'role' | 'tabIndex'
569570

570571
let ItemsRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -577,6 +578,7 @@ export type MenuItemsProps<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>
577578
anchor?: AnchorProps
578579
portal?: boolean
579580
modal?: boolean
581+
transition?: boolean
580582

581583
// ItemsRenderFeatures
582584
static?: boolean
@@ -594,6 +596,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
594596
anchor: rawAnchor,
595597
portal = false,
596598
modal = true,
599+
transition = false,
597600
...theirProps
598601
} = props
599602
let anchor = useResolvedAnchor(rawAnchor)
@@ -608,16 +611,14 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
608611
portal = true
609612
}
610613

611-
let searchDisposables = useDisposables()
612-
613614
let usesOpenClosedState = useOpenClosed()
614-
let visible = (() => {
615-
if (usesOpenClosedState !== null) {
616-
return (usesOpenClosedState & State.Open) === State.Open
617-
}
618-
619-
return state.menuState === MenuStates.Open
620-
})()
615+
let [visible, transitionData] = useTransitionData(
616+
transition,
617+
state.itemsRef,
618+
usesOpenClosedState !== null
619+
? (usesOpenClosedState & State.Open) === State.Open
620+
: state.menuState === MenuStates.Open
621+
)
621622

622623
// Ensure we close the menu as soon as the button becomes hidden
623624
useOnDisappear(visible, state.buttonRef, () => {
@@ -671,6 +672,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
671672
},
672673
})
673674

675+
let searchDisposables = useDisposables()
674676
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
675677
searchDisposables.dispose()
676678

@@ -755,10 +757,12 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
755757
}
756758
})
757759

758-
let slot = useMemo(
759-
() => ({ open: state.menuState === MenuStates.Open }) satisfies ItemsRenderPropArg,
760-
[state]
761-
)
760+
let slot = useMemo(() => {
761+
return {
762+
open: state.menuState === MenuStates.Open,
763+
...transitionData,
764+
} satisfies ItemsRenderPropArg
765+
}, [state, transitionData])
762766

763767
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
764768
'aria-activedescendant':

0 commit comments

Comments
 (0)