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