diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index f8c3cc473d..4b7fe6d887 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259)) - Cancel outside click behavior on touch devices when scrolling ([#3266](https://github.com/tailwindlabs/headlessui/pull/3266)) - Correctly apply conditional classses when using `` and `` components ([#3303](https://github.com/tailwindlabs/headlessui/pull/3303)) +- Improve UX by freezing `ComboboxOptions` while closing ([#3304](https://github.com/tailwindlabs/headlessui/pull/3304)) ### Changed diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 6927a4e7f1..f89e5140a8 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -54,6 +54,7 @@ import { type AnchorProps, } from '../../internal/floating' import { FormFields } from '../../internal/form-fields' +import { Frozen, useFrozenData } from '../../internal/frozen' import { useProvidedId } from '../../internal/id' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import type { EnsureArray, Props } from '../../types' @@ -1707,28 +1708,38 @@ function OptionsFn( onMouseDown: handleMouseDown, }) + // We should freeze when the combobox is visible but "closed". This means that + // a transition is currently happening and the component is still visible (for + // the transition) but closed from a functionality perspective. + let shouldFreeze = visible && data.comboboxState === ComboboxState.Closed + + let options = useFrozenData(shouldFreeze, data.virtual?.options) + + // Frozen state, the selected value will only update visually when the user re-opens the + let frozenValue = useFrozenData(shouldFreeze, data.value) + + let isSelected = useEvent((compareValue) => data.compare(frozenValue, compareValue)) + // Map the children in a scrollable container when virtualization is enabled - if (data.virtual && visible) { + if (data.virtual) { + if (options === undefined) throw new Error('Missing `options` in virtual mode') + Object.assign(theirProps, { - // @ts-expect-error The `children` prop now is a callback function that receives `{ option }`. - children: {theirProps.children}, + children: ( + + {/* @ts-expect-error The `children` prop now is a callback function that receives `{option}` */} + {theirProps.children} + + ), }) } - // Frozen state, the selected value will only update visually when the user re-opens the - let [frozenValue, setFrozenValue] = useState(data.value) - if ( - data.value !== frozenValue && - data.comboboxState === ComboboxState.Open && - data.mode !== ValueMode.Multi - ) { - setFrozenValue(data.value) - } - - let isSelected = useEvent((compareValue: unknown) => { - return data.compare(frozenValue, compareValue) - }) - return ( ( > {render({ ourProps, - theirProps, + theirProps: { + ...theirProps, + children: ( + + {typeof theirProps.children === 'function' + ? // @ts-expect-error The `children` prop now is a callback function + theirProps.children?.(slot) + : theirProps.children} + + ), + }, slot, defaultTag: DEFAULT_OPTIONS_TAG, features: OptionsRenderFeatures, diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 3cf106953f..6f84675b5d 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -12,7 +12,6 @@ import React, { useMemo, useReducer, useRef, - useState, type CSSProperties, type ElementType, type MutableRefObject, @@ -54,6 +53,7 @@ import { type AnchorPropsWithSelection, } from '../../internal/floating' import { FormFields } from '../../internal/form-fields' +import { useFrozenData } from '../../internal/frozen' import { useProvidedId } from '../../internal/id' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import type { EnsureArray, Props } from '../../types' @@ -1115,18 +1115,15 @@ function OptionsFn( } as CSSProperties, }) + // We should freeze when the listbox is visible but "closed". This means that + // a transition is currently happening and the component is still visible (for + // the transition) but closed from a functionality perspective. + let shouldFreeze = visible && data.listboxState === ListboxStates.Closed + // Frozen state, the selected value will only update visually when the user re-opens the - let [frozenValue, setFrozenValue] = useState(data.value) - if ( - data.value !== frozenValue && - data.listboxState === ListboxStates.Open && - data.mode !== ValueMode.Multi - ) { - setFrozenValue(data.value) - } - let isSelected = useEvent((compareValue: unknown) => { - return data.compare(frozenValue, compareValue) - }) + let frozenValue = useFrozenData(shouldFreeze, data.value) + + let isSelected = useEvent((compareValue: unknown) => data.compare(frozenValue, compareValue)) return ( diff --git a/packages/@headlessui-react/src/internal/frozen.tsx b/packages/@headlessui-react/src/internal/frozen.tsx new file mode 100644 index 0000000000..6deffe31b5 --- /dev/null +++ b/packages/@headlessui-react/src/internal/frozen.tsx @@ -0,0 +1,19 @@ +import React, { useState } from 'react' + +export function Frozen({ children, freeze }: { children: React.ReactNode; freeze: boolean }) { + let contents = useFrozenData(freeze, children) + return <>{contents} +} + +export function useFrozenData(freeze: boolean, data: T) { + let [frozenValue, setFrozenValue] = useState(data) + + // We should keep updating the frozen value, as long as we shouldn't freeze + // the value yet. The moment we should freeze the value we stop updating it + // which allows us to reference the "previous" (thus frozen) value. + if (!freeze && frozenValue !== data) { + setFrozenValue(data) + } + + return freeze ? frozenValue : data +} diff --git a/playgrounds/react/pages/combobox/combobox-countries.tsx b/playgrounds/react/pages/combobox/combobox-countries.tsx index 0ae9a813e1..02a7a0b5ad 100644 --- a/playgrounds/react/pages/combobox/combobox-countries.tsx +++ b/playgrounds/react/pages/combobox/combobox-countries.tsx @@ -72,51 +72,53 @@ export default function Home() { -
- - {countries.map((country) => ( - { - return classNames( - 'relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', - active ? 'bg-indigo-600 text-white' : 'text-gray-900' - ) - }} - > - {({ active, selected }) => ( - <> + + {countries.map((country) => ( + { + return classNames( + 'relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', + active ? 'bg-indigo-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {country} + + {selected && ( - {country} + + + - {selected && ( - - - - - - )} - - )} - - ))} - -
+ )} + + )} + + ))} +
diff --git a/playgrounds/react/pages/combobox/combobox-virtualized.tsx b/playgrounds/react/pages/combobox/combobox-virtualized.tsx index 700749eb19..9873ff5034 100644 --- a/playgrounds/react/pages/combobox/combobox-virtualized.tsx +++ b/playgrounds/react/pages/combobox/combobox-virtualized.tsx @@ -104,101 +104,107 @@ function Example({ virtual = true, data, initial }: { virtual?: boolean; data; i -
- {virtual ? ( - - {({ option }) => { - return ( - { - return classNames( - 'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', - active ? 'bg-indigo-600 text-white' : 'text-gray-900' - ) - }} - > - {({ active, selected }) => ( - <> + {virtual ? ( + + {({ option }) => { + return ( + { + return classNames( + 'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', + active ? 'bg-indigo-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {option as any} + + {selected && ( - {option as any} + + + - {selected && ( - - - - - + )} + + )} + + ) + }} + + ) : ( + + {timezones.map((timezone, idx) => { + return ( + { + return classNames( + 'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', + active ? 'bg-indigo-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + - )} - - ) - }} - - ) : ( - - {timezones.map((timezone, idx) => { - return ( - { - return classNames( - 'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', - active ? 'bg-indigo-600 text-white' : 'text-gray-900' - ) - }} - > - {({ active, selected }) => ( - <> + > + {timezone} + + {selected && ( - {timezone} + + + - {selected && ( - - - - - - )} - - )} - - ) - })} - - )} -
+ )} + + )} + + ) + })} + + )}
diff --git a/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx b/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx index 7ee9ecbfe3..0d845b098a 100644 --- a/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx +++ b/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx @@ -73,12 +73,12 @@ export default function Home() { - + {name} - +