Skip to content

Commit 1c3f9a6

Browse files
authored
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
1 parent 29e7d94 commit 1c3f9a6

File tree

7 files changed

+203
-157
lines changed

7 files changed

+203
-157
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259))
2323
- Cancel outside click behavior on touch devices when scrolling ([#3266](https://github.com/tailwindlabs/headlessui/pull/3266))
2424
- Correctly apply conditional classses when using `<Transition />` and `<TransitionChild />` components ([#3303](https://github.com/tailwindlabs/headlessui/pull/3303))
25+
- Improve UX by freezing `ComboboxOptions` while closing ([#3304](https://github.com/tailwindlabs/headlessui/pull/3304))
2526

2627
### Changed
2728

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
type AnchorProps,
5555
} from '../../internal/floating'
5656
import { FormFields } from '../../internal/form-fields'
57+
import { Frozen, useFrozenData } from '../../internal/frozen'
5758
import { useProvidedId } from '../../internal/id'
5859
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
5960
import type { EnsureArray, Props } from '../../types'
@@ -1707,36 +1708,56 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
17071708
onMouseDown: handleMouseDown,
17081709
})
17091710

1711+
// We should freeze when the combobox is visible but "closed". This means that
1712+
// a transition is currently happening and the component is still visible (for
1713+
// the transition) but closed from a functionality perspective.
1714+
let shouldFreeze = visible && data.comboboxState === ComboboxState.Closed
1715+
1716+
let options = useFrozenData(shouldFreeze, data.virtual?.options)
1717+
1718+
// Frozen state, the selected value will only update visually when the user re-opens the <Combobox />
1719+
let frozenValue = useFrozenData(shouldFreeze, data.value)
1720+
1721+
let isSelected = useEvent((compareValue) => data.compare(frozenValue, compareValue))
1722+
17101723
// Map the children in a scrollable container when virtualization is enabled
1711-
if (data.virtual && visible) {
1724+
if (data.virtual) {
1725+
if (options === undefined) throw new Error('Missing `options` in virtual mode')
1726+
17121727
Object.assign(theirProps, {
1713-
// @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
1714-
children: <VirtualProvider slot={slot}>{theirProps.children}</VirtualProvider>,
1728+
children: (
1729+
<ComboboxDataContext.Provider
1730+
value={
1731+
options !== data.virtual.options
1732+
? { ...data, virtual: { ...data.virtual, options } }
1733+
: data
1734+
}
1735+
>
1736+
{/* @ts-expect-error The `children` prop now is a callback function that receives `{option}` */}
1737+
<VirtualProvider slot={slot}>{theirProps.children}</VirtualProvider>
1738+
</ComboboxDataContext.Provider>
1739+
),
17151740
})
17161741
}
17171742

1718-
// Frozen state, the selected value will only update visually when the user re-opens the <Combobox />
1719-
let [frozenValue, setFrozenValue] = useState(data.value)
1720-
if (
1721-
data.value !== frozenValue &&
1722-
data.comboboxState === ComboboxState.Open &&
1723-
data.mode !== ValueMode.Multi
1724-
) {
1725-
setFrozenValue(data.value)
1726-
}
1727-
1728-
let isSelected = useEvent((compareValue: unknown) => {
1729-
return data.compare(frozenValue, compareValue)
1730-
})
1731-
17321743
return (
17331744
<Portal enabled={portal ? props.static || visible : false}>
17341745
<ComboboxDataContext.Provider
17351746
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
17361747
>
17371748
{render({
17381749
ourProps,
1739-
theirProps,
1750+
theirProps: {
1751+
...theirProps,
1752+
children: (
1753+
<Frozen freeze={shouldFreeze}>
1754+
{typeof theirProps.children === 'function'
1755+
? // @ts-expect-error The `children` prop now is a callback function
1756+
theirProps.children?.(slot)
1757+
: theirProps.children}
1758+
</Frozen>
1759+
),
1760+
},
17401761
slot,
17411762
defaultTag: DEFAULT_OPTIONS_TAG,
17421763
features: OptionsRenderFeatures,

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import React, {
1212
useMemo,
1313
useReducer,
1414
useRef,
15-
useState,
1615
type CSSProperties,
1716
type ElementType,
1817
type MutableRefObject,
@@ -54,6 +53,7 @@ import {
5453
type AnchorPropsWithSelection,
5554
} from '../../internal/floating'
5655
import { FormFields } from '../../internal/form-fields'
56+
import { useFrozenData } from '../../internal/frozen'
5757
import { useProvidedId } from '../../internal/id'
5858
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
5959
import type { EnsureArray, Props } from '../../types'
@@ -1115,18 +1115,15 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
11151115
} as CSSProperties,
11161116
})
11171117

1118+
// We should freeze when the listbox is visible but "closed". This means that
1119+
// a transition is currently happening and the component is still visible (for
1120+
// the transition) but closed from a functionality perspective.
1121+
let shouldFreeze = visible && data.listboxState === ListboxStates.Closed
1122+
11181123
// Frozen state, the selected value will only update visually when the user re-opens the <Listbox />
1119-
let [frozenValue, setFrozenValue] = useState(data.value)
1120-
if (
1121-
data.value !== frozenValue &&
1122-
data.listboxState === ListboxStates.Open &&
1123-
data.mode !== ValueMode.Multi
1124-
) {
1125-
setFrozenValue(data.value)
1126-
}
1127-
let isSelected = useEvent((compareValue: unknown) => {
1128-
return data.compare(frozenValue, compareValue)
1129-
})
1124+
let frozenValue = useFrozenData(shouldFreeze, data.value)
1125+
1126+
let isSelected = useEvent((compareValue: unknown) => data.compare(frozenValue, compareValue))
11301127

11311128
return (
11321129
<Portal enabled={portal ? props.static || visible : false}>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React, { useState } from 'react'
2+
3+
export function Frozen({ children, freeze }: { children: React.ReactNode; freeze: boolean }) {
4+
let contents = useFrozenData(freeze, children)
5+
return <>{contents}</>
6+
}
7+
8+
export function useFrozenData<T>(freeze: boolean, data: T) {
9+
let [frozenValue, setFrozenValue] = useState(data)
10+
11+
// We should keep updating the frozen value, as long as we shouldn't freeze
12+
// the value yet. The moment we should freeze the value we stop updating it
13+
// which allows us to reference the "previous" (thus frozen) value.
14+
if (!freeze && frozenValue !== data) {
15+
setFrozenValue(data)
16+
}
17+
18+
return freeze ? frozenValue : data
19+
}

playgrounds/react/pages/combobox/combobox-countries.tsx

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -72,51 +72,53 @@ export default function Home() {
7272
</Combobox.Button>
7373
</span>
7474

75-
<div className="absolute mt-1 rounded-md bg-white shadow-lg">
76-
<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">
77-
{countries.map((country) => (
78-
<Combobox.Option
79-
key={country}
80-
value={country}
81-
className={({ active }) => {
82-
return classNames(
83-
'relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
84-
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
85-
)
86-
}}
87-
>
88-
{({ active, selected }) => (
89-
<>
75+
<Combobox.Options
76+
transition
77+
anchor="bottom start"
78+
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"
79+
>
80+
{countries.map((country) => (
81+
<Combobox.Option
82+
key={country}
83+
value={country}
84+
className={({ active }) => {
85+
return classNames(
86+
'relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
87+
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
88+
)
89+
}}
90+
>
91+
{({ active, selected }) => (
92+
<>
93+
<span
94+
className={classNames(
95+
'block truncate',
96+
selected ? 'font-semibold' : 'font-normal'
97+
)}
98+
>
99+
{country}
100+
</span>
101+
{selected && (
90102
<span
91103
className={classNames(
92-
'block truncate',
93-
selected ? 'font-semibold' : 'font-normal'
104+
'absolute inset-y-0 right-0 flex items-center pr-4',
105+
active ? 'text-white' : 'text-indigo-600'
94106
)}
95107
>
96-
{country}
108+
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
109+
<path
110+
fillRule="evenodd"
111+
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"
112+
clipRule="evenodd"
113+
/>
114+
</svg>
97115
</span>
98-
{selected && (
99-
<span
100-
className={classNames(
101-
'absolute inset-y-0 right-0 flex items-center pr-4',
102-
active ? 'text-white' : 'text-indigo-600'
103-
)}
104-
>
105-
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
106-
<path
107-
fillRule="evenodd"
108-
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"
109-
clipRule="evenodd"
110-
/>
111-
</svg>
112-
</span>
113-
)}
114-
</>
115-
)}
116-
</Combobox.Option>
117-
))}
118-
</Combobox.Options>
119-
</div>
116+
)}
117+
</>
118+
)}
119+
</Combobox.Option>
120+
))}
121+
</Combobox.Options>
120122
</div>
121123
</Combobox>
122124
</div>

0 commit comments

Comments
 (0)