Skip to content

fix(vue): prevent auto-selecting option on input blur when no user input #3772

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
98 changes: 98 additions & 0 deletions packages/@headlessui-vue/src/components/combobox/combobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<Combobox v-model="value" name="assignee" by="id" @update:modelValue="handleChange">
<ComboboxInput />
<ComboboxButton />
<ComboboxOptions>
<ComboboxOption v-for="person in data" :key="person.id" :value="person">
{{ person.label }}
</ComboboxOption>
</ComboboxOptions>
</Combobox>
`,
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`
<Combobox v-model="value" name="assignee" by="id" @update:modelValue="handleChange">
<ComboboxInput />
<ComboboxButton />
<ComboboxOptions>
<ComboboxOption v-for="person in data" :key="person.id" :value="person">
{{ person.label }}
</ComboboxOption>
</ComboboxOptions>
</Combobox>
`,
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', () => {
Expand Down
31 changes: 14 additions & 17 deletions packages/@headlessui-vue/src/components/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link

@mochetts mochetts Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think blurring the input should ever select an option.
See how shadcn works:

https://ui.shadcn.com/docs/components/combobox

That's what i would expect from a combobox (blurring cancels edition).

}
}
}

return api.closeCombobox()
api.closeCombobox()
}

function handleFocus(event: FocusEvent) {
Expand Down