From b96469102ec10aeb3547b552be1d809446496451 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 29 Aug 2023 11:39:17 +0200 Subject: [PATCH 1/3] ensure blurring the `Combobox.Input` component closes the `Combobox` --- .../src/components/combobox/combobox.test.tsx | 38 ++++++++++++++++++ .../src/components/combobox/combobox.tsx | 28 ++++++++++++- .../src/test-utils/interactions.ts | 18 +++++++++ .../src/components/combobox/combobox.test.ts | 39 +++++++++++++++++++ .../src/components/combobox/combobox.ts | 33 +++++++++++++++- .../src/test-utils/interactions.ts | 18 +++++++++ 6 files changed, 172 insertions(+), 2 deletions(-) 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..b98a6315bd 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,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 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/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..20ac27cfaa 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -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(() => { 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) From c2233ce17f7afd6f94f650de8c8b22a0bcbe1bf4 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 29 Aug 2023 11:41:37 +0200 Subject: [PATCH 2/3] update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + packages/@headlessui-vue/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) 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-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 From b6e0978deea9ed1832e4931da7d54c3e4a0dbac3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 29 Aug 2023 16:15:13 +0200 Subject: [PATCH 3/3] select the value on blur if we are in single value mode --- .../src/components/combobox/combobox.tsx | 9 +++++++-- .../@headlessui-vue/src/components/combobox/combobox.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index b98a6315bd..ed6fec8bf7 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -1035,15 +1035,20 @@ function InputFn< if (data.comboboxState !== ComboboxState.Open) return event.preventDefault() - if (data.nullable && data.mode === ValueMode.Single) { + 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.value === null) { + 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() diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 20ac27cfaa..06fd7fc44f 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -1002,15 +1002,20 @@ export let ComboboxInput = defineComponent({ if (api.comboboxState.value !== ComboboxStates.Open) return event.preventDefault() - if (api.nullable.value && api.mode.value === ValueMode.Single) { + 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.value.value === null) { + 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()