From 134c0fb2213e766f08053eb49201b78cb2eb1ef5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 29 Aug 2023 16:24:37 +0200 Subject: [PATCH] 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 --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/components/combobox/combobox.test.tsx | 38 ++++++++++++++++++ .../src/components/combobox/combobox.tsx | 33 +++++++++++++++- .../src/test-utils/interactions.ts | 18 +++++++++ packages/@headlessui-vue/CHANGELOG.md | 1 + .../src/components/combobox/combobox.test.ts | 39 +++++++++++++++++++ .../src/components/combobox/combobox.ts | 38 +++++++++++++++++- .../src/test-utils/interactions.ts | 18 +++++++++ 8 files changed, 184 insertions(+), 2 deletions(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 66d5a0ac41..ce8a3bfc61 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Lazily resolve default containers in `` ([#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 `` 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 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index e1f4b55800..bac14cf8cf 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -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( + + + + + {data.map((person) => ( + + {person.label} + + ))} + + + ) + + // 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', () => { diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 5e7f38a1ec..ed6fec8bf7 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -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 diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index dd0298fd7b..bef82315a7 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -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) diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 75ec46ba9b..7c16795e9f 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -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 `` 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 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index dedeeaca85..e5f24832f9 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -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` + + + + + + {{ person.label }} + + + + `, + 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', () => { diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index a5bec86433..06fd7fc44f 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -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(() => { diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts index 612849023b..58418680e9 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -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)