Skip to content

Commit 134c0fb

Browse files
authored
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
1 parent c6ac692 commit 134c0fb

File tree

8 files changed

+184
-2
lines changed

8 files changed

+184
-2
lines changed

packages/@headlessui-react/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Lazily resolve default containers in `<Dialog>` ([#2697](https://github.com/tailwindlabs/headlessui/pull/2697))
1414
- Ensure hidden `Tab.Panel` components are hidden from the accessibility tree ([#2708](https://github.com/tailwindlabs/headlessui/pull/2708))
1515
- Add support for `role="alertdialog"` to `<Dialog>` component ([#2709](https://github.com/tailwindlabs/headlessui/pull/2709))
16+
- Ensure blurring the `Combobox.Input` component closes the `Combobox` ([#2712](https://github.com/tailwindlabs/headlessui/pull/2712))
1617

1718
## [1.7.17] - 2023-08-17
1819

packages/@headlessui-react/src/components/combobox/combobox.test.tsx

+38
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppre
66
import {
77
click,
88
focus,
9+
blur,
910
mouseMove,
1011
mouseLeave,
1112
press,
@@ -449,6 +450,43 @@ describe('Rendering', () => {
449450
)
450451
})
451452
)
453+
454+
it(
455+
'should close the Combobox when the input is blurred',
456+
suppressConsoleLogs(async () => {
457+
let data = [
458+
{ id: 1, name: 'alice', label: 'Alice' },
459+
{ id: 2, name: 'bob', label: 'Bob' },
460+
{ id: 3, name: 'charlie', label: 'Charlie' },
461+
]
462+
463+
render(
464+
<Combobox name="assignee" by="id">
465+
<Combobox.Input onChange={NOOP} />
466+
<Combobox.Button />
467+
<Combobox.Options>
468+
{data.map((person) => (
469+
<Combobox.Option key={person.id} value={person}>
470+
{person.label}
471+
</Combobox.Option>
472+
))}
473+
</Combobox.Options>
474+
</Combobox>
475+
)
476+
477+
// Open the combobox
478+
await click(getComboboxButton())
479+
480+
// Verify it is open
481+
assertComboboxList({ state: ComboboxState.Visible })
482+
483+
// Close the combobox
484+
await blur(getComboboxInput())
485+
486+
// Verify it is closed
487+
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
488+
})
489+
)
452490
})
453491

454492
describe('Combobox.Input', () => {

packages/@headlessui-react/src/components/combobox/combobox.tsx

+32-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import React, {
1313
ElementType,
1414
KeyboardEvent as ReactKeyboardEvent,
1515
MouseEvent as ReactMouseEvent,
16+
FocusEvent as ReactFocusEvent,
1617
MutableRefObject,
1718
Ref,
1819
} from 'react'
@@ -1019,8 +1020,38 @@ function InputFn<
10191020
actions.openCombobox()
10201021
})
10211022

1022-
let handleBlur = useEvent(() => {
1023+
let handleBlur = useEvent((event: ReactFocusEvent) => {
10231024
isTyping.current = false
1025+
1026+
// Focus is moved into the list, we don't want to close yet.
1027+
if (data.optionsRef.current?.contains(event.relatedTarget)) {
1028+
return
1029+
}
1030+
1031+
if (data.buttonRef.current?.contains(event.relatedTarget)) {
1032+
return
1033+
}
1034+
1035+
if (data.comboboxState !== ComboboxState.Open) return
1036+
event.preventDefault()
1037+
1038+
if (data.mode === ValueMode.Single) {
1039+
// We want to clear the value when the user presses escape if and only if the current
1040+
// value is not set (aka, they didn't select anything yet, or they cleared the input which
1041+
// caused the value to be set to `null`). If the current value is set, then we want to
1042+
// fallback to that value when we press escape (this part is handled in the watcher that
1043+
// syncs the value with the input field again).
1044+
if (data.nullable && data.value === null) {
1045+
clear()
1046+
}
1047+
1048+
// We do have a value, so let's select the active option
1049+
else {
1050+
actions.selectActiveOption()
1051+
}
1052+
}
1053+
1054+
return actions.closeCombobox()
10241055
})
10251056

10261057
// TODO: Verify this. The spec says that, for the input/combobox, the label is the labelling element when present

packages/@headlessui-react/src/test-utils/interactions.ts

+18
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,24 @@ export async function focus(element: Document | Element | Window | Node | null)
295295
throw err
296296
}
297297
}
298+
299+
export async function blur(element: Document | Element | Window | Node | null) {
300+
try {
301+
if (element === null) return expect(element).not.toBe(null)
302+
303+
if (element instanceof HTMLElement) {
304+
element.blur()
305+
} else {
306+
fireEvent.blur(element)
307+
}
308+
309+
await new Promise(nextFrame)
310+
} catch (err) {
311+
if (err instanceof Error) Error.captureStackTrace(err, blur)
312+
throw err
313+
}
314+
}
315+
298316
export async function mouseEnter(element: Document | Element | Window | null) {
299317
try {
300318
if (element === null) return expect(element).not.toBe(null)

packages/@headlessui-vue/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Fix Portal SSR hydration mismatches ([#2700](https://github.com/tailwindlabs/headlessui/pull/2700))
1414
- Ensure hidden `TabPanel` components are hidden from the accessibility tree ([#2708](https://github.com/tailwindlabs/headlessui/pull/2708))
1515
- Add support for `role="alertdialog"` to `<Dialog>` component ([#2709](https://github.com/tailwindlabs/headlessui/pull/2709))
16+
- Ensure blurring the `ComboboxInput` component closes the `Combobox` ([#2712](https://github.com/tailwindlabs/headlessui/pull/2712))
1617

1718
## [1.7.16] - 2023-08-17
1819

packages/@headlessui-vue/src/components/combobox/combobox.test.ts

+39
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
1212
import {
1313
click,
1414
focus,
15+
blur,
1516
mouseMove,
1617
mouseLeave,
1718
press,
@@ -500,6 +501,44 @@ describe('Rendering', () => {
500501
})
501502
})
502503
)
504+
505+
it(
506+
'should close the Combobox when the input is blurred',
507+
suppressConsoleLogs(async () => {
508+
let data = [
509+
{ id: 1, name: 'alice', label: 'Alice' },
510+
{ id: 2, name: 'bob', label: 'Bob' },
511+
{ id: 3, name: 'charlie', label: 'Charlie' },
512+
]
513+
514+
renderTemplate({
515+
template: html`
516+
<Combobox name="assignee" by="id">
517+
<ComboboxInput />
518+
<ComboboxButton />
519+
<ComboboxOptions>
520+
<ComboboxOption v-for="person in data" :key="person.id" :value="person">
521+
{{ person.label }}
522+
</ComboboxOption>
523+
<ComboboxOptions>
524+
</Combobox>
525+
`,
526+
setup: () => ({ data }),
527+
})
528+
529+
// Open the combobox
530+
await click(getComboboxButton())
531+
532+
// Verify it is open
533+
assertComboboxList({ state: ComboboxState.Visible })
534+
535+
// Close the combobox
536+
await blur(getComboboxInput())
537+
538+
// Verify it is closed
539+
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
540+
})
541+
)
503542
})
504543

505544
describe('ComboboxInput', () => {

packages/@headlessui-vue/src/components/combobox/combobox.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -981,8 +981,44 @@ export let ComboboxInput = defineComponent({
981981
api.openCombobox()
982982
}
983983

984-
function handleBlur() {
984+
function handleBlur(event: FocusEvent) {
985985
isTyping.value = false
986+
987+
// Focus is moved into the list, we don't want to close yet.
988+
if (
989+
event.relatedTarget instanceof Node &&
990+
dom(api.optionsRef)?.contains(event.relatedTarget)
991+
) {
992+
return
993+
}
994+
995+
if (
996+
event.relatedTarget instanceof Node &&
997+
dom(api.buttonRef)?.contains(event.relatedTarget)
998+
) {
999+
return
1000+
}
1001+
1002+
if (api.comboboxState.value !== ComboboxStates.Open) return
1003+
event.preventDefault()
1004+
1005+
if (api.mode.value === ValueMode.Single) {
1006+
// We want to clear the value when the user presses escape if and only if the current
1007+
// value is not set (aka, they didn't select anything yet, or they cleared the input which
1008+
// caused the value to be set to `null`). If the current value is set, then we want to
1009+
// fallback to that value when we press escape (this part is handled in the watcher that
1010+
// syncs the value with the input field again).
1011+
if (api.nullable.value && api.value.value === null) {
1012+
clear()
1013+
}
1014+
1015+
// We do have a value, so let's select the active option
1016+
else {
1017+
api.selectActiveOption()
1018+
}
1019+
}
1020+
1021+
return api.closeCombobox()
9861022
}
9871023

9881024
let defaultValue = computed(() => {

packages/@headlessui-vue/src/test-utils/interactions.ts

+18
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,24 @@ export async function focus(element: Document | Element | Window | Node | null)
293293
throw err
294294
}
295295
}
296+
297+
export async function blur(element: Document | Element | Window | Node | null) {
298+
try {
299+
if (element === null) return expect(element).not.toBe(null)
300+
301+
if (element instanceof HTMLElement) {
302+
element.blur()
303+
} else {
304+
fireEvent.blur(element)
305+
}
306+
307+
await new Promise(nextFrame)
308+
} catch (err) {
309+
if (err instanceof Error) Error.captureStackTrace(err, blur)
310+
throw err
311+
}
312+
}
313+
296314
export async function mouseEnter(element: Document | Element | Window | null) {
297315
try {
298316
if (element === null) return expect(element).not.toBe(null)

0 commit comments

Comments
 (0)