Skip to content

Commit

Permalink
Add immediate prop to <Combobox /> for immediately opening the Co…
Browse files Browse the repository at this point in the history
…mbobox when the `input` receives focus (#2686)

* Allow to open combobox on input focus

* Close focused combobox with openOnFocus prop when clicking the button

* ensure tabbing through a few fields, doesn't result in an incorrectly selected item

When you have a fwe inputs such as:

```html
<form>
   <input />
   <input />
   <input />
   <Combobox>
      <Combobox.Input />
   </Combobox>
   <input />
   <input />
   <input />
</form>
```

Tabbing through this list will open the combobox once you are on the
input field. When you continue tabbing, the first item would be
selected. However, if the combobox is not marked as nullable, it means
that just going through the form means that we set a value we can't
unset anymore.

We still want to open the combobox, we just don't want to select
anything in this case.

* only `openOnFocus` if the `<Combobox.Input />` is focused from the
outside

If the focus is coming from the `<Combobox.Button />` or as a side
effect of selecting an `<Combobox.Option />` then we don't want to
re-open the `<Combobox />`

* update tests to ensure that the `Combobox.Input` is the active element

* order `handleBlur` and `handleFocus` the same way in Vue & React

* only select the active option when the Combobox wasn't opened by focusing the input field

* convert to `immediate` prop on the `Combobox` itself

* update changelog

* ensure we see the "relatedTarget" in Safari

Safari doesn't fire a `focus` event when clicking a button, therefore it
does not become the `document.activeElement`, and events like `blur` or
`focus` doesn't set the button as the `event.relatedTarget`.

Keeping track of a history like this solves that problem. We already had
the code for the `FocusTrap` component.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
  • Loading branch information
tzurbaev and RobinMalfait committed Aug 31, 2023
1 parent 5a1e2e4 commit fa95262
Show file tree
Hide file tree
Showing 12 changed files with 870 additions and 73 deletions.
4 changes: 4 additions & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `role="alertdialog"` to `<Dialog>` component ([#2709](https://github.com/tailwindlabs/headlessui/pull/2709))
- Ensure blurring the `Combobox.Input` component closes the `Combobox` ([#2712](https://github.com/tailwindlabs/headlessui/pull/2712))

### Added

- Add `immediate` prop to `<Combobox />` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686))

## [1.7.17] - 2023-08-17

### Fixed
Expand Down
199 changes: 199 additions & 0 deletions packages/@headlessui-react/src/components/combobox/combobox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4698,6 +4698,176 @@ describe('Mouse interactions', () => {
})
)

it(
'should be possible to open the combobox by focusing the input with immediate mode enabled',
suppressConsoleLogs(async () => {
render(
<Combobox value="test" immediate>
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="a">Option A</Combobox.Option>
<Combobox.Option value="b">Option B</Combobox.Option>
<Combobox.Option value="c">Option C</Combobox.Option>
</Combobox.Options>
</Combobox>
)

assertComboboxButton({
state: ComboboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })

// Focus the input
await focus(getComboboxInput())

// Verify it is visible
assertComboboxButton({ state: ComboboxState.Visible })
assertComboboxList({
state: ComboboxState.Visible,
attributes: { id: 'headlessui-combobox-options-3' },
})
assertActiveElement(getComboboxInput())
assertComboboxButtonLinkedWithCombobox()

// Verify we have combobox options
let options = getComboboxOptions()
expect(options).toHaveLength(3)
options.forEach((option) => assertComboboxOption(option))
})
)

it(
'should not be possible to open the combobox by focusing the input with immediate mode disabled',
suppressConsoleLogs(async () => {
render(
<Combobox value="test">
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="a">Option A</Combobox.Option>
<Combobox.Option value="b">Option B</Combobox.Option>
<Combobox.Option value="c">Option C</Combobox.Option>
</Combobox.Options>
</Combobox>
)

assertComboboxButton({
state: ComboboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })

// Focus the input
await focus(getComboboxInput())

// Verify it is invisible
assertComboboxButton({
state: ComboboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
})
)

it(
'should not be possible to open the combobox by focusing the input with immediate mode enabled when button is disabled',
suppressConsoleLogs(async () => {
render(
<Combobox value="test" disabled immediate>
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="a">Option A</Combobox.Option>
<Combobox.Option value="b">Option B</Combobox.Option>
<Combobox.Option value="c">Option C</Combobox.Option>
</Combobox.Options>
</Combobox>
)

assertComboboxButton({
state: ComboboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })

// Focus the input
await focus(getComboboxInput())

// Verify it is invisible
assertComboboxButton({
state: ComboboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-combobox-button-2' },
})
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
})
)

it(
'should be possible to close a combobox on click with immediate mode enabled',
suppressConsoleLogs(async () => {
render(
<Combobox value="test" immediate>
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="a">Option A</Combobox.Option>
<Combobox.Option value="b">Option B</Combobox.Option>
<Combobox.Option value="c">Option C</Combobox.Option>
</Combobox.Options>
</Combobox>
)

// Open combobox
await click(getComboboxButton())

// Verify it is visible
assertComboboxButton({ state: ComboboxState.Visible })

// Click to close
await click(getComboboxButton())

// Verify it is closed
assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
assertActiveElement(getComboboxInput())
})
)

it(
'should be possible to close a focused combobox on click with immediate mode enabled',
suppressConsoleLogs(async () => {
render(
<Combobox value="test" immediate>
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="a">Option A</Combobox.Option>
<Combobox.Option value="b">Option B</Combobox.Option>
<Combobox.Option value="c">Option C</Combobox.Option>
</Combobox.Options>
</Combobox>
)
assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })

// Open combobox by focusing input
await focus(getComboboxInput())
assertActiveElement(getComboboxInput())

// Verify it is visible
assertComboboxButton({ state: ComboboxState.Visible })

// Click to close
await click(getComboboxButton())

// Verify it is closed
assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
assertActiveElement(getComboboxInput())
})
)

it(
'should be possible to open the combobox on click',
suppressConsoleLogs(async () => {
Expand Down Expand Up @@ -5358,6 +5528,35 @@ describe('Mouse interactions', () => {
})
)

it(
'should be possible to click a combobox option, which closes the combobox with immediate mode enabled',
suppressConsoleLogs(async () => {
render(
<Combobox value="test" immediate>
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="a">Option A</Combobox.Option>
<Combobox.Option value="b">Option B</Combobox.Option>
<Combobox.Option value="c">Option C</Combobox.Option>
</Combobox.Options>
</Combobox>
)

// Open combobox by focusing input
await focus(getComboboxInput())
assertActiveElement(getComboboxInput())

assertComboboxList({ state: ComboboxState.Visible })

let options = getComboboxOptions()

// We should be able to click the first option
await click(options[1])
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
})
)

it(
'should be possible to click a disabled combobox option, which is a no-op',
suppressConsoleLogs(async () => {
Expand Down

2 comments on commit fa95262

@vercel
Copy link

@vercel vercel bot commented on fa95262 Aug 31, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

headlessui-vue – ./packages/playground-vue

headlessui-vue-tailwindlabs.vercel.app
headlessui-vue-git-main-tailwindlabs.vercel.app
headlessui-vue.vercel.app

@vercel
Copy link

@vercel vercel bot commented on fa95262 Aug 31, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

headlessui-react – ./packages/playground-react

headlessui-react.vercel.app
headlessui-react-tailwindlabs.vercel.app
headlessui-react-git-main-tailwindlabs.vercel.app

Please sign in to comment.