From 1c3f9a6230900269871b92a3dec8606c3d893652 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 20 Jun 2024 16:15:07 +0200 Subject: [PATCH] Improve UX by freezing `` component while closing (#3304) * add internal `Frozen` component and `useFrozenData` hook * implement frozen state for the `Combobox` component When the `Combobox` is in a closed state, but still visible (aka transitioning out), then we want to freeze the `children` of the `ComboboxOptions`. This way we still look at the old list while transitioning out and you can safely reset any `state` that filters the options in the `onClose` callback. Note: we want to only freeze the children of the `ComboboxOptions`, not the `ComboboxOptions` itself because we are still applying the necessary data attributes to make the transition happen. Similarly, if you are using the `virtual` prop, then we only freeze the `virtual.options` and render the _old_ list while transitioning out. * use `useFrozenData` in `Listbox` component * use `data-*` attributes and `transition` prop to simplify playgrounds * update changelog * improve comment * simplify frozen conditions * use existing variable for frozen state --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/components/combobox/combobox.tsx | 57 ++++-- .../src/components/listbox/listbox.tsx | 21 +-- .../@headlessui-react/src/internal/frozen.tsx | 19 ++ .../pages/combobox/combobox-countries.tsx | 82 +++++---- .../pages/combobox/combobox-virtualized.tsx | 174 +++++++++--------- .../listbox/listbox-with-pure-tailwind.tsx | 6 +- 7 files changed, 203 insertions(+), 157 deletions(-) create mode 100644 packages/@headlessui-react/src/internal/frozen.tsx 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} - +