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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user