diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 0715ee888..6581adfb3 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -1796,6 +1796,104 @@ describe('Rendering', () => { await click(getComboboxButton()) }) ) + + it( + 'should not auto-select when input was focused and blurred without typing', + // Prevents console warnings during test execution + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + const data = [ + { id: 1, name: 'alice', label: 'Alice' }, + { id: 2, name: 'bob', label: 'Bob' }, + ] + + renderTemplate({ + template: html` + + + + + + {{ person.label }} + + + + `, + setup: () => ({ data, value: ref(null), handleChange }), + }) + + const input = getComboboxInput() + expect(input).not.toBeNull() + + // Simulate user focusing the input but not typing or selecting anything + await focus(input!) + + // Then immediately blurring (e.g., clicking away or tabbing out) + await blur(input!) + + // Assert that no value was selected on blur + // Regression check: ensures we don't auto-select the first option + expect(handleChange).not.toHaveBeenCalled() + }) + ) + + it( + 'should not auto-select the active option when the combobox input is blurred', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + 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, + value: ref(null), + handleChange, + }), + }) + + // Verify initial state - no selection + expect(handleChange).not.toHaveBeenCalled() + + // Open the combobox + await click(getComboboxButton()) + + // Verify it is open and first option is active + assertComboboxList({ state: ComboboxState.Visible }) + let options = getComboboxOptions() + assertActiveComboboxOption(options[0]) + + // Verify no option is selected yet + assertNoSelectedComboboxOption() + + // Blur the combobox input (this is where the bug occurs) + await blur(getComboboxInput()) + + // Verify it is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // The value should still be null and handleChange should not have been called + expect(handleChange).not.toHaveBeenCalled() + + // If we open the combobox again, there should still be no selected option + await click(getComboboxButton()) + assertNoSelectedComboboxOption() + }) + ) }) describe('Rendering composition', () => { diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index b5149f271..90b67de9b 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -1215,38 +1215,35 @@ export let ComboboxInput = defineComponent({ function handleBlur(event: FocusEvent) { let relatedTarget = (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) - isTyping.value = false - // Focus is moved into the list, we don't want to close yet. - if (dom(api.optionsRef)?.contains(relatedTarget)) { - return - } + isTyping.value = false - if (dom(api.buttonRef)?.contains(relatedTarget)) { - return - } + // Preserve dropdown if focus moved within combobox (e.g., to options or button). + if (dom(api.optionsRef)?.contains(relatedTarget)) return + if (dom(api.buttonRef)?.contains(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 nullable and user cleared the input, ensure value is cleared explicitly. if (api.nullable.value && api.value.value === null) { clear() } - // We do have a value, so let's select the active option, unless we were just going through - // the form and we opened it due to the focus event. + // Avoid unintended auto-selection when combobox opened on focus (e.g., on page load or tab focus). + // Only select active option if the user typed something and triggered it intentionally. else if (api.activationTrigger.value !== ActivationTrigger.Focus) { - api.selectActiveOption() + const inputValue = dom(api.inputRef)?.value || '' + const hasUserInput = inputValue.trim().length > 0 + + if (hasUserInput) { + api.selectActiveOption() + } } } - return api.closeCombobox() + api.closeCombobox() } function handleFocus(event: FocusEvent) {