Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 3 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
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
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