From 6b6c25901051c1cdb5439e4bc3ec3be448cdc9cf Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 11 Jun 2024 17:53:21 +0200 Subject: [PATCH] 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 `` 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. --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/components/combobox/combobox.tsx | 27 ++- .../src/components/disclosure/disclosure.tsx | 28 ++- .../src/components/listbox/listbox.tsx | 29 ++- .../src/components/menu/menu.tsx | 32 +-- .../src/components/popover/popover.tsx | 63 +++--- .../__snapshots__/transition.test.tsx.snap | 158 +++++++++++-- .../components/transition/transition.test.tsx | 33 ++- .../src/components/transition/transition.tsx | 4 + .../components/transition/utils/transition.ts | 4 +- .../@headlessui-react/src/hooks/use-flags.ts | 32 +-- .../src/hooks/use-transition-data.ts | 213 ++++++++++++++++++ 12 files changed, 491 insertions(+), 133 deletions(-) create mode 100644 packages/@headlessui-react/src/hooks/use-transition-data.ts diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 4322c31290..e912d7076c 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add ability to render multiple `` 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 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 40a34f6472..89008c717c 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -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 let VirtualContext = createContext | 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: { {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 @@ -1589,6 +1592,7 @@ function OptionsFn( anchor: rawAnchor, portal = false, modal = true, + transition = false, ...theirProps } = props let data = useData('Combobox.Options') @@ -1606,13 +1610,13 @@ function OptionsFn( 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( 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( if (data.virtual && visible) { Object.assign(theirProps, { // @ts-expect-error The `children` prop now is a callback function that receives `{ option }`. - children: {theirProps.children}, + children: {theirProps.children}, }) } diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index c27d8f5740..fcf624fbde 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -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) => void -} +} & TransitionData type DisclosurePanelPropsWeControl = never let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static @@ -428,7 +429,7 @@ export type DisclosurePanelProps + { transition?: boolean } & PropsForFeatures > function PanelFn( @@ -436,7 +437,11 @@ function PanelFn( ref: Ref ) { 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( }, [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, diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 5781ebf52e..e0c45daffa 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -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 > @@ -895,6 +897,7 @@ function OptionsFn( anchor: rawAnchor, portal = false, modal = true, + transition = false, ...theirProps } = props let anchor = useResolvedAnchor(rawAnchor) @@ -910,13 +913,13 @@ function OptionsFn( 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( }) 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, diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index eb34c94ea5..3619aac8ed 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -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( 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 anchor?: AnchorProps portal?: boolean modal?: boolean + transition?: boolean // ItemsRenderFeatures static?: boolean @@ -594,6 +596,7 @@ function ItemsFn( anchor: rawAnchor, portal = false, modal = true, + transition = false, ...theirProps } = props let anchor = useResolvedAnchor(rawAnchor) @@ -608,16 +611,14 @@ function ItemsFn( 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( }, }) + let searchDisposables = useDisposables() let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { searchDisposables.dispose() @@ -755,10 +757,12 @@ function ItemsFn( } }) - 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': diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 057409734f..cc8cf852e3 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -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( 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 + { transition?: boolean } & PropsForFeatures > function OverlayFn( @@ -743,28 +744,31 @@ function OverlayFn( ref: Ref ) { 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(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) => void -} +} & TransitionData let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static @@ -805,6 +809,7 @@ export type PopoverPanelProps( anchor: rawAnchor, portal = false, modal = false, + transition = false, ...theirProps } = props @@ -856,13 +862,13 @@ function PanelFn( }, [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( 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 = mergeProps(anchor ? getFloatingPanelProps() : {}, { ref: panelRef, diff --git a/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap b/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap index c00a2019cb..f133165928 100644 --- a/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap +++ b/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap @@ -4,11 +4,29 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
-
-