Skip to content

Commit

Permalink
Ensure blurring the Combobox.Input component closes the Combobox (#…
Browse files Browse the repository at this point in the history
…2712)

* ensure blurring the `Combobox.Input` component closes the `Combobox`

* update changelog

* select the value on blur if we are in single value mode
  • Loading branch information
RobinMalfait authored Aug 29, 2023
1 parent c6ac692 commit 134c0fb
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 2 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Lazily resolve default containers in `<Dialog>` ([#2697](https://github.com/tailwindlabs/headlessui/pull/2697))
- Ensure hidden `Tab.Panel` components are hidden from the accessibility tree ([#2708](https://github.com/tailwindlabs/headlessui/pull/2708))
- 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))

## [1.7.17] - 2023-08-17

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppre
import {
click,
focus,
blur,
mouseMove,
mouseLeave,
press,
Expand Down Expand Up @@ -449,6 +450,43 @@ describe('Rendering', () => {
)
})
)

it(
'should close the Combobox when the input is blurred',
suppressConsoleLogs(async () => {
let data = [
{ id: 1, name: 'alice', label: 'Alice' },
{ id: 2, name: 'bob', label: 'Bob' },
{ id: 3, name: 'charlie', label: 'Charlie' },
]

render(
<Combobox name="assignee" by="id">
<Combobox.Input onChange={NOOP} />
<Combobox.Button />
<Combobox.Options>
{data.map((person) => (
<Combobox.Option key={person.id} value={person}>
{person.label}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)

// Open the combobox
await click(getComboboxButton())

// Verify it is open
assertComboboxList({ state: ComboboxState.Visible })

// Close the combobox
await blur(getComboboxInput())

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

describe('Combobox.Input', () => {
Expand Down
33 changes: 32 additions & 1 deletion packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import React, {
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
FocusEvent as ReactFocusEvent,
MutableRefObject,
Ref,
} from 'react'
Expand Down Expand Up @@ -1019,8 +1020,38 @@ function InputFn<
actions.openCombobox()
})

let handleBlur = useEvent(() => {
let handleBlur = useEvent((event: ReactFocusEvent) => {
isTyping.current = false

// Focus is moved into the list, we don't want to close yet.
if (data.optionsRef.current?.contains(event.relatedTarget)) {
return
}

if (data.buttonRef.current?.contains(event.relatedTarget)) {
return
}

if (data.comboboxState !== ComboboxState.Open) return
event.preventDefault()

if (data.mode === ValueMode.Single) {
// We want to clear the value when the user presses escape if and only if the current
// value is not set (aka, they didn't select anything yet, or they cleared the input which
// caused the value to be set to `null`). If the current value is set, then we want to
// fallback to that value when we press escape (this part is handled in the watcher that
// syncs the value with the input field again).
if (data.nullable && data.value === null) {
clear()
}

// We do have a value, so let's select the active option
else {
actions.selectActiveOption()
}
}

return actions.closeCombobox()
})

// TODO: Verify this. The spec says that, for the input/combobox, the label is the labelling element when present
Expand Down
18 changes: 18 additions & 0 deletions packages/@headlessui-react/src/test-utils/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,24 @@ export async function focus(element: Document | Element | Window | Node | null)
throw err
}
}

export async function blur(element: Document | Element | Window | Node | null) {
try {
if (element === null) return expect(element).not.toBe(null)

if (element instanceof HTMLElement) {
element.blur()
} else {
fireEvent.blur(element)
}

await new Promise(nextFrame)
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, blur)
throw err
}
}

export async function mouseEnter(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix Portal SSR hydration mismatches ([#2700](https://github.com/tailwindlabs/headlessui/pull/2700))
- Ensure hidden `TabPanel` components are hidden from the accessibility tree ([#2708](https://github.com/tailwindlabs/headlessui/pull/2708))
- Add support for `role="alertdialog"` to `<Dialog>` component ([#2709](https://github.com/tailwindlabs/headlessui/pull/2709))
- Ensure blurring the `ComboboxInput` component closes the `Combobox` ([#2712](https://github.com/tailwindlabs/headlessui/pull/2712))

## [1.7.16] - 2023-08-17

Expand Down
39 changes: 39 additions & 0 deletions packages/@headlessui-vue/src/components/combobox/combobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
import {
click,
focus,
blur,
mouseMove,
mouseLeave,
press,
Expand Down Expand Up @@ -500,6 +501,44 @@ describe('Rendering', () => {
})
})
)

it(
'should close the Combobox when the input is blurred',
suppressConsoleLogs(async () => {
let data = [
{ id: 1, name: 'alice', label: 'Alice' },
{ id: 2, name: 'bob', label: 'Bob' },
{ id: 3, name: 'charlie', label: 'Charlie' },
]

renderTemplate({
template: html`
<Combobox name="assignee" by="id">
<ComboboxInput />
<ComboboxButton />
<ComboboxOptions>
<ComboboxOption v-for="person in data" :key="person.id" :value="person">
{{ person.label }}
</ComboboxOption>
<ComboboxOptions>
</Combobox>
`,
setup: () => ({ data }),
})

// Open the combobox
await click(getComboboxButton())

// Verify it is open
assertComboboxList({ state: ComboboxState.Visible })

// Close the combobox
await blur(getComboboxInput())

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

describe('ComboboxInput', () => {
Expand Down
38 changes: 37 additions & 1 deletion packages/@headlessui-vue/src/components/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,8 +981,44 @@ export let ComboboxInput = defineComponent({
api.openCombobox()
}

function handleBlur() {
function handleBlur(event: FocusEvent) {
isTyping.value = false

// Focus is moved into the list, we don't want to close yet.
if (
event.relatedTarget instanceof Node &&
dom(api.optionsRef)?.contains(event.relatedTarget)
) {
return
}

if (
event.relatedTarget instanceof Node &&
dom(api.buttonRef)?.contains(event.relatedTarget)
) {
return
}

if (api.comboboxState.value !== ComboboxStates.Open) return
event.preventDefault()

if (api.mode.value === ValueMode.Single) {
// We want to clear the value when the user presses escape if and only if the current
// value is not set (aka, they didn't select anything yet, or they cleared the input which
// caused the value to be set to `null`). If the current value is set, then we want to
// fallback to that value when we press escape (this part is handled in the watcher that
// syncs the value with the input field again).
if (api.nullable.value && api.value.value === null) {
clear()
}

// We do have a value, so let's select the active option
else {
api.selectActiveOption()
}
}

return api.closeCombobox()
}

let defaultValue = computed(() => {
Expand Down
18 changes: 18 additions & 0 deletions packages/@headlessui-vue/src/test-utils/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,24 @@ export async function focus(element: Document | Element | Window | Node | null)
throw err
}
}

export async function blur(element: Document | Element | Window | Node | null) {
try {
if (element === null) return expect(element).not.toBe(null)

if (element instanceof HTMLElement) {
element.blur()
} else {
fireEvent.blur(element)
}

await new Promise(nextFrame)
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, blur)
throw err
}
}

export async function mouseEnter(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
Expand Down

2 comments on commit 134c0fb

@vercel
Copy link

@vercel vercel bot commented on 134c0fb Aug 29, 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-git-main-tailwindlabs.vercel.app
headlessui-vue.vercel.app
headlessui-vue-tailwindlabs.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 134c0fb Aug 29, 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-git-main-tailwindlabs.vercel.app
headlessui-react-tailwindlabs.vercel.app
headlessui-react.vercel.app

Please sign in to comment.