Ensure blurring the Combobox.Input component closes the Combobox (#2712)

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

* update changelog

* select the value on blur if we are in single value mode
This commit is contained in:
Robin Malfait
2023-08-29 16:24:37 +02:00
committed by GitHub
parent c6ac69237a
commit 134c0fb221
8 changed files with 184 additions and 2 deletions
+1
View File
@@ -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
@@ -6,6 +6,7 @@ import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppre
import {
click,
focus,
blur,
mouseMove,
mouseLeave,
press,
@@ -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', () => {
@@ -13,6 +13,7 @@ import React, {
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
FocusEvent as ReactFocusEvent,
MutableRefObject,
Ref,
} from 'react'
@@ -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
@@ -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)
+1
View File
@@ -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
@@ -12,6 +12,7 @@ import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
import {
click,
focus,
blur,
mouseMove,
mouseLeave,
press,
@@ -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', () => {
@@ -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(() => {
@@ -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)