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

Conversation

RobinMalfait
Copy link
Member

This PR improves the UX when you are closing a Combobox that uses a Transition while closing.

Typically the Combobox component is used with a filtered list based on the value of the ComboboxInput. When the Combobox closes then the onClose will be called. In this function you typically reset the search query state as well:

import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from '@headlessui/react'
import { useState } from 'react'

const people = [
  { id: 1, name: 'Durward Reynolds' },
  { id: 2, name: 'Kenton Towne' },
  { id: 3, name: 'Therese Wunsch' },
  { id: 4, name: 'Benedict Kessler' },
  { id: 5, name: 'Katelyn Rohan' },
]

function Example() {
  const [selectedPerson, setSelectedPerson] = useState(people[0])
  const [query, setQuery] = useState('')

  const filteredPeople =
    query === ''
      ? people
      : people.filter((person) => {
          return person.name.toLowerCase().includes(query.toLowerCase())
        })

  return (
    <Combobox value={selectedPerson} onChange={setSelectedPerson} onClose={() => setQuery('')}>
      <ComboboxInput
        aria-label="Assignee"
        displayValue={(person) => person?.name}
        onChange={(event) => setQuery(event.target.value)}
      />
      <ComboboxOptions anchor="bottom" className="empty:hidden">
        {filteredPeople.map((person) => (
          <ComboboxOption key={person.id} value={person} className="data-[focus]:bg-blue-100">
            {person.name}
          </ComboboxOption>
        ))}
      </ComboboxOptions>
    </Combobox>
  )
}

Notice that we use setQuery('') in the onClose.

If you are using a transition, then the following things will happen:

  1. The Combobox is closed
  2. The onClose is triggered, the ComboboxOptions are transitioning out
  3. The query is reset
  4. The filteredPeople list is updated (not filtered yet)
  5. The ComboboxOptions re-renders with the "full" list instead of the filtered list.
  6. The Transition completes after this.

This now means that while transitioning out, the ComboboxOptions is re-rendering with a new list of options even though you just selected an option.

This PR now will "freeze" that state so that we show the contents at the time of closing the Combobox.

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.
Copy link

vercel bot commented Jun 20, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
headlessui-react ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 20, 2024 2:05pm
headlessui-vue ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 20, 2024 2:05pm

Comment on lines +11 to +16
// 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)
}
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. 🤔

Comment on lines 1756 to 1766
theirProps: {
...theirProps,
children: (
<Frozen freeze={visible && data.comboboxState === ComboboxState.Closed}>
{typeof theirProps.children === 'function'
? // @ts-expect-error The `children` prop now is a callback function
theirProps.children?.(slot)
: theirProps.children}
</Frozen>
),
},
Copy link
Member Author

Choose a reason for hiding this comment

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

Had to do this here to make sure we only freeze the children, and not the ComboboxOptions itself (aka, the return render())

Copy link
Contributor

Choose a reason for hiding this comment

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

It feels a bit weird because it kinda feels like this should still be the responsibility of render (or a similar helper). But, having said that, this is fine for now.

// 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')
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 😅

Copy link
Contributor

@thecrypticace thecrypticace left a comment

Choose a reason for hiding this comment

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

Small tweaks is all but afaict it's working well. I did find a case where I hit an error — but not sure if related (maybe?):

  1. Go to http://localhost:3000/combobox/combobox-virtualized
  2. Open the first combobox
  3. Select all
  4. Delete
  5. Hit return
  6. Press a while it's fading out

Comment on lines 1756 to 1766
theirProps: {
...theirProps,
children: (
<Frozen freeze={visible && data.comboboxState === ComboboxState.Closed}>
{typeof theirProps.children === 'function'
? // @ts-expect-error The `children` prop now is a callback function
theirProps.children?.(slot)
: theirProps.children}
</Frozen>
),
},
Copy link
Contributor

Choose a reason for hiding this comment

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

It feels a bit weird because it kinda feels like this should still be the responsibility of render (or a similar helper). But, having said that, this is fine for now.

@RobinMalfait RobinMalfait changed the title Add frozen state to ComboboxOptions Improve UX by freezing <ComboboxOptions /> component while closing Jun 20, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants