Improve UX by freezing <ComboboxOptions /> 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
This commit is contained in:
Robin Malfait
2024-06-20 16:15:07 +02:00
committed by GitHub
parent 29e7d94503
commit 1c3f9a6230
7 changed files with 204 additions and 158 deletions
+1
View File
@@ -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 `<Transition />` and `<TransitionChild />` 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
@@ -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,27 +1708,37 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
onMouseDown: handleMouseDown,
})
// Map the children in a scrollable container when virtualization is enabled
if (data.virtual && visible) {
Object.assign(theirProps, {
// @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
children: <VirtualProvider slot={slot}>{theirProps.children}</VirtualProvider>,
})
}
// 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 <Combobox />
let [frozenValue, setFrozenValue] = useState(data.value)
if (
data.value !== frozenValue &&
data.comboboxState === ComboboxState.Open &&
data.mode !== ValueMode.Multi
) {
setFrozenValue(data.value)
}
let frozenValue = useFrozenData(shouldFreeze, data.value)
let isSelected = useEvent((compareValue: unknown) => {
return data.compare(frozenValue, compareValue)
})
let isSelected = useEvent((compareValue) => data.compare(frozenValue, compareValue))
// Map the children in a scrollable container when virtualization is enabled
if (data.virtual) {
if (options === undefined) throw new Error('Missing `options` in virtual mode')
Object.assign(theirProps, {
children: (
<ComboboxDataContext.Provider
value={
options !== data.virtual.options
? { ...data, virtual: { ...data.virtual, options } }
: data
}
>
{/* @ts-expect-error The `children` prop now is a callback function that receives `{option}` */}
<VirtualProvider slot={slot}>{theirProps.children}</VirtualProvider>
</ComboboxDataContext.Provider>
),
})
}
return (
<Portal enabled={portal ? props.static || visible : false}>
@@ -1736,7 +1747,17 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
>
{render({
ourProps,
theirProps,
theirProps: {
...theirProps,
children: (
<Frozen freeze={shouldFreeze}>
{typeof theirProps.children === 'function'
? // @ts-expect-error The `children` prop now is a callback function
theirProps.children?.(slot)
: theirProps.children}
</Frozen>
),
},
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
@@ -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<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
} 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 <Listbox />
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 (
<Portal enabled={portal ? props.static || visible : false}>
@@ -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<T>(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
}
@@ -72,51 +72,53 @@ export default function Home() {
</Combobox.Button>
</span>
<div className="absolute mt-1 rounded-md bg-white shadow-lg">
<Combobox.Options className="shadow-xs max-h-60 w-[calc(var(--input-width)+var(--button-width))] overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
{countries.map((country) => (
<Combobox.Option
key={country}
value={country}
className={({ active }) => {
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 }) => (
<>
<Combobox.Options
transition
anchor="bottom start"
className="w-[calc(var(--input-width)+var(--button-width))] overflow-auto rounded-md bg-white py-1 text-base leading-6 shadow-lg transition duration-1000 [--anchor-gap:theme(spacing.1)] [--anchor-max-height:theme(spacing.60)] focus:outline-none data-[closed]:opacity-0 sm:text-sm sm:leading-5"
>
{countries.map((country) => (
<Combobox.Option
key={country}
value={country}
className={({ active }) => {
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 }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{country}
</span>
{selected && (
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
{country}
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</div>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</Combobox>
</div>
@@ -104,101 +104,107 @@ function Example({ virtual = true, data, initial }: { virtual?: boolean; data; i
</Combobox.Button>
</span>
<div className="absolute mt-1 w-full rounded-md bg-white shadow-lg">
{virtual ? (
<Combobox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
{({ option }) => {
return (
<Combobox.Option
value={option}
className={({ active }) => {
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 ? (
<Combobox.Options
transition
anchor="bottom start"
className="w-[calc(var(--input-width)+var(--button-width))] overflow-auto rounded-md bg-white py-1 text-base leading-6 shadow-lg transition duration-1000 [--anchor-gap:theme(spacing.1)] [--anchor-max-height:theme(spacing.60)] focus:outline-none data-[closed]:opacity-0 sm:text-sm sm:leading-5"
>
{({ option }) => {
return (
<Combobox.Option
value={option}
className={({ active }) => {
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 }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{option as any}
</span>
{selected && (
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
{option as any}
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
)
}}
</Combobox.Options>
) : (
<Combobox.Options
transition
anchor="bottom start"
className="w-[calc(var(--input-width)+var(--button-width))] overflow-auto rounded-md bg-white py-1 text-base leading-6 shadow-lg transition duration-1000 [--anchor-gap:theme(spacing.1)] [--anchor-max-height:theme(spacing.60)] focus:outline-none data-[closed]:opacity-0 sm:text-sm sm:leading-5"
>
{timezones.map((timezone, idx) => {
return (
<Combobox.Option
key={timezone}
order={virtual ? idx : undefined}
value={timezone}
className={({ active }) => {
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 }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
</>
)}
</Combobox.Option>
)
}}
</Combobox.Options>
) : (
<Combobox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
{timezones.map((timezone, idx) => {
return (
<Combobox.Option
key={timezone}
order={virtual ? idx : undefined}
value={timezone}
className={({ active }) => {
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}
</span>
{selected && (
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
{timezone}
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
)
})}
</Combobox.Options>
)}
</div>
)}
</>
)}
</Combobox.Option>
)
})}
</Combobox.Options>
)}
</div>
</Combobox>
</div>
@@ -73,12 +73,12 @@ export default function Home() {
<Listbox.Option
key={name}
value={name}
className="ui-active:bg-indigo-600 ui-active:text-white ui-not-active:text-gray-900 relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none"
className="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 focus:outline-none data-[active]:bg-indigo-600 data-[active]:text-white"
>
<span className="ui-selected:font-semibold ui-not-selected:font-normal block truncate">
<span className="block truncate font-normal group-data-[selected]:font-semibold">
{name}
</span>
<span className="ui-not-selected:hidden ui-selected:flex ui-active:text-white ui-not-active:text-indigo-600 absolute inset-y-0 right-0 items-center pr-4">
<span className="absolute inset-y-0 right-0 hidden items-center pr-4 text-indigo-600 group-data-[selected]:flex group-data-[active]:text-white">
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"