From 8c5c57586fe72e18fbb9bd49305aa11ac4d1a276 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 6 Jun 2024 12:47:07 +0200 Subject: [PATCH 01/10] simplify `useFlags` --- .../@headlessui-react/src/hooks/use-flags.ts | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-flags.ts b/packages/@headlessui-react/src/hooks/use-flags.ts index 4f9cb84ce2..f7d1590852 100644 --- a/packages/@headlessui-react/src/hooks/use-flags.ts +++ b/packages/@headlessui-react/src/hooks/use-flags.ts @@ -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 } } From 5c8c7fea7e25e8ba66aea78291ab59a43a748540 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 6 Jun 2024 12:45:45 +0200 Subject: [PATCH 02/10] add new `useTransitionData` hook --- .../components/transition/utils/transition.ts | 2 +- .../src/hooks/use-transition-data.ts | 204 ++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 packages/@headlessui-react/src/hooks/use-transition-data.ts diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.ts b/packages/@headlessui-react/src/components/transition/utils/transition.ts index fc2e31ebda..0eb0006211 100644 --- a/packages/@headlessui-react/src/components/transition/utils/transition.ts +++ b/packages/@headlessui-react/src/components/transition/utils/transition.ts @@ -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() diff --git a/packages/@headlessui-react/src/hooks/use-transition-data.ts b/packages/@headlessui-react/src/hooks/use-transition-data.ts new file mode 100644 index 0000000000..c621606b10 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-transition-data.ts @@ -0,0 +1,204 @@ +import { useRef, useState, type MutableRefObject } from 'react' +import { 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, + 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 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() { + inFlight.current = true + + if (show) { + addFlag(TransitionState.Enter | TransitionState.From) + removeFlag(TransitionState.Exit) + } else { + addFlag(TransitionState.Exit) + removeFlag(TransitionState.Enter) + } + }, + run() { + if (show) { + removeFlag(TransitionState.From) + } else { + addFlag(TransitionState.From) + } + }, + done() { + 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 + } +) { + 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 +} + +function prepareTransition( + node: HTMLElement, + { + prepare, + inFlight, + }: { + prepare: () => void + inFlight: MutableRefObject + } +) { + if (inFlight.current) { + prepare() + return + } + + let previous = node.style.transition + + // Force cancel current transition + node.style.transition = 'none' + + prepare() + + // Trigger a reflow, flushing the CSS changes + node.offsetHeight + + // Reset the transition to what it was before + node.style.transition = previous +} From b84bed69520f25d8932043b8fc0807e0de01cf50 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 5 Jun 2024 09:34:51 +0200 Subject: [PATCH 03/10] use new `useTransitionData` hook --- .../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 +++++++++++-------- 5 files changed, 104 insertions(+), 75 deletions(-) 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, From 9b17b6f4c7ab09bf1a740525cc6212639c3072df Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 5 Jun 2024 13:57:53 +0200 Subject: [PATCH 04/10] add ability to cancel transitions mid-transition --- .../src/hooks/use-transition-data.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-transition-data.ts b/packages/@headlessui-react/src/hooks/use-transition-data.ts index c621606b10..54f537b74a 100644 --- a/packages/@headlessui-react/src/hooks/use-transition-data.ts +++ b/packages/@headlessui-react/src/hooks/use-transition-data.ts @@ -50,6 +50,7 @@ export function useTransitionData( visible ? TransitionState.From : TransitionState.None ) let inFlight = useRef(false) + let cancelledRef = useRef(false) let d = useDisposables() @@ -74,8 +75,12 @@ export function useTransitionData( return transition(node, { inFlight, prepare() { + cancelledRef.current = inFlight.current + inFlight.current = true + if (cancelledRef.current) return + if (show) { addFlag(TransitionState.Enter | TransitionState.From) removeFlag(TransitionState.Exit) @@ -85,13 +90,25 @@ export function useTransitionData( } }, run() { - if (show) { - removeFlag(TransitionState.From) + if (cancelledRef.current) { + if (show) { + removeFlag(TransitionState.Exit | TransitionState.From) + addFlag(TransitionState.Enter) + } else { + removeFlag(TransitionState.Enter) + addFlag(TransitionState.Exit | TransitionState.From) + } } else { - addFlag(TransitionState.From) + if (show) { + removeFlag(TransitionState.From) + } else { + addFlag(TransitionState.From) + } } }, done() { + if (cancelledRef.current) return + inFlight.current = false removeFlag(TransitionState.Enter | TransitionState.Exit | TransitionState.From) From 4c2747ddd398a774db517ca32069e7745a84354c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 5 Jun 2024 23:12:27 +0200 Subject: [PATCH 05/10] handle cancellations in both directions properly --- .../src/hooks/use-transition-data.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-transition-data.ts b/packages/@headlessui-react/src/hooks/use-transition-data.ts index 54f537b74a..2bc38d8c0b 100644 --- a/packages/@headlessui-react/src/hooks/use-transition-data.ts +++ b/packages/@headlessui-react/src/hooks/use-transition-data.ts @@ -75,7 +75,14 @@ export function useTransitionData( return transition(node, { inFlight, prepare() { - cancelledRef.current = inFlight.current + 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 @@ -91,12 +98,22 @@ export function useTransitionData( }, 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.Exit | TransitionState.From) - addFlag(TransitionState.Enter) + removeFlag(TransitionState.Enter | TransitionState.From) + addFlag(TransitionState.Exit) } else { - removeFlag(TransitionState.Enter) - addFlag(TransitionState.Exit | TransitionState.From) + removeFlag(TransitionState.Exit) + addFlag(TransitionState.Enter | TransitionState.From) } } else { if (show) { @@ -107,7 +124,9 @@ export function useTransitionData( } }, done() { - if (cancelledRef.current) return + if (cancelledRef.current && node.getAnimations().length > 0) { + return + } inFlight.current = false From ff444f8e1564e9c18d0ed96a6345e1895394fcc2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 6 Jun 2024 01:07:29 +0200 Subject: [PATCH 06/10] re-use existing `prepareTransition` --- .../components/transition/utils/transition.ts | 2 +- .../src/hooks/use-transition-data.ts | 31 +------------------ 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.ts b/packages/@headlessui-react/src/components/transition/utils/transition.ts index 0eb0006211..a3ea34e94f 100644 --- a/packages/@headlessui-react/src/components/transition/utils/transition.ts +++ b/packages/@headlessui-react/src/components/transition/utils/transition.ts @@ -184,7 +184,7 @@ export function transition( return d.dispose } -function prepareTransition( +export function prepareTransition( node: HTMLElement, { inFlight, prepare }: { inFlight?: MutableRefObject; prepare: () => void } ) { diff --git a/packages/@headlessui-react/src/hooks/use-transition-data.ts b/packages/@headlessui-react/src/hooks/use-transition-data.ts index 2bc38d8c0b..09d9358575 100644 --- a/packages/@headlessui-react/src/hooks/use-transition-data.ts +++ b/packages/@headlessui-react/src/hooks/use-transition-data.ts @@ -1,5 +1,5 @@ import { useRef, useState, type MutableRefObject } from 'react' -import { waitForTransition } from '../components/transition/utils/transition' +import { prepareTransition, waitForTransition } from '../components/transition/utils/transition' import { disposables } from '../utils/disposables' import { useDisposables } from './use-disposables' import { useFlags } from './use-flags' @@ -209,32 +209,3 @@ function transition( return d.dispose } - -function prepareTransition( - node: HTMLElement, - { - prepare, - inFlight, - }: { - prepare: () => void - inFlight: MutableRefObject - } -) { - if (inFlight.current) { - prepare() - return - } - - let previous = node.style.transition - - // Force cancel current transition - node.style.transition = 'none' - - prepare() - - // Trigger a reflow, flushing the CSS changes - node.offsetHeight - - // Reset the transition to what it was before - node.style.transition = previous -} From da2f3ce9257686e5b0b9047c498d9fdb277624d8 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 6 Jun 2024 12:36:20 +0200 Subject: [PATCH 07/10] expose `data-*` attributes for transitions in `` component --- .../src/components/transition/transition.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx index d2ddadfe3a..2ab9916276 100644 --- a/packages/@headlessui-react/src/components/transition/transition.tsx +++ b/packages/@headlessui-react/src/components/transition/transition.tsx @@ -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 Date: Fri, 7 Jun 2024 11:22:08 +0200 Subject: [PATCH 08/10] update tests to reflect added data attributes --- .../__snapshots__/transition.test.tsx.snap | 158 +++++++++++++++--- .../components/transition/transition.test.tsx | 33 ++-- 2 files changed, 160 insertions(+), 31 deletions(-) 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
-
-