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
  • Loading branch information
RobinMalfait committed Aug 29, 2023
1 parent c6ac692 commit b964691
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 2 deletions.
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
28 changes: 27 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,33 @@ 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.nullable && 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.value === null) {
clear()
}
}

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
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
33 changes: 32 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,39 @@ 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.nullable.value && 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.value.value === null) {
clear()
}
}

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

0 comments on commit b964691

Please sign in to comment.