Skip to content

Commit

Permalink
Introduce CSS based transitions (#3273)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
RobinMalfait authored Jun 11, 2024
1 parent 03c22b4 commit 6b6c259
Show file tree
Hide file tree
Showing 12 changed files with 491 additions and 133 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 16 additions & 11 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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
Expand All @@ -1575,6 +1577,7 @@ export type ComboboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTIO
anchor?: AnchorProps
portal?: boolean
modal?: boolean
transition?: boolean
}
>

Expand All @@ -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')
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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>,
})
}

Expand Down
28 changes: 17 additions & 11 deletions packages/@headlessui-react/src/components/disclosure/disclosure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -428,15 +429,19 @@ 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>(
props: DisclosurePanelProps<TTag>,
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()
Expand All @@ -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,
Expand Down
29 changes: 17 additions & 12 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -882,6 +883,7 @@ export type ListboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTION
anchor?: AnchorPropsWithSelection
portal?: boolean
modal?: boolean
transition?: boolean
} & PropsForFeatures<typeof OptionsRenderFeatures>
>

Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 18 additions & 14 deletions packages/@headlessui-react/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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, () => {
Expand Down Expand Up @@ -671,6 +672,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
},
})

let searchDisposables = useDisposables()
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
searchDisposables.dispose()

Expand Down Expand Up @@ -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':
Expand Down
Loading

0 comments on commit 6b6c259

Please sign in to comment.