Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve UX by freezing <ComboboxOptions /> component while closing #3304

Merged
merged 8 commits into from
Jun 20, 2024
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 39 additions & 18 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1707,36 +1708,56 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
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 <Combobox />
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) {
RobinMalfait marked this conversation as resolved.
Show resolved Hide resolved
if (options === undefined) throw new Error('Missing `options` in virtual mode')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make TypeScript happy 😅


Object.assign(theirProps, {
// @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
children: <VirtualProvider slot={slot}>{theirProps.children}</VirtualProvider>,
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>
),
})
}

// 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 isSelected = useEvent((compareValue: unknown) => {
return data.compare(frozenValue, compareValue)
})

return (
<Portal enabled={portal ? props.static || visible : false}>
<ComboboxDataContext.Provider
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
>
{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,
Expand Down
21 changes: 9 additions & 12 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import React, {
useMemo,
useReducer,
useRef,
useState,
type CSSProperties,
type ElementType,
type MutableRefObject,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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}>
Expand Down
19 changes: 19 additions & 0 deletions packages/@headlessui-react/src/internal/frozen.tsx
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +11 to +16
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice because we don't use any effects, but it will trigger a setFrozenValue every time the data changes. Ideally we only change this state the moment we go from false to true and capture the old value somehow. 🤔


return freeze ? frozenValue : data
}
82 changes: 42 additions & 40 deletions playgrounds/react/pages/combobox/combobox-countries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading
Loading