Skip to content

Commit

Permalink
Improve UX by freezing <ComboboxOptions /> component while closing (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
RobinMalfait committed Jun 20, 2024
1 parent 29e7d94 commit 1c3f9a6
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 157 deletions.
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) {
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: <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)
}

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

0 comments on commit 1c3f9a6

Please sign in to comment.