Skip to content

Commit bc1db72

Browse files
authored
fix(Combobox): prevent addOnBlur from adding raw input when selecting item (#2514)
* fix(Combobox): prevent `addOnBlur` from adding raw input when selecting item * chore: comments
1 parent 06d2f71 commit bc1db72

4 files changed

Lines changed: 135 additions & 1 deletion

File tree

packages/core/src/Combobox/Combobox.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { nextTick } from 'vue'
66
import { handleSubmit, sleep } from '@/test'
77
import Combobox from './story/_Combobox.vue'
88
import ComboboxObject from './story/_ComboboxObject.vue'
9+
import ComboboxTagsInput from './story/_ComboboxTagsInput.vue'
910

1011
describe('given default Combobox', () => {
1112
let wrapper: VueWrapper<InstanceType<typeof Combobox>>
@@ -403,3 +404,57 @@ describe('given combobox in a form', async () => {
403404
})
404405
})
405406
})
407+
408+
describe('given Combobox with TagsInput and addOnBlur', () => {
409+
let wrapper: VueWrapper<InstanceType<typeof ComboboxTagsInput>>
410+
let input: DOMWrapper<HTMLInputElement>
411+
412+
beforeEach(() => {
413+
document.body.innerHTML = ''
414+
wrapper = mount(ComboboxTagsInput, {
415+
props: { addOnBlur: true },
416+
attachTo: document.body,
417+
})
418+
input = wrapper.find('input')
419+
})
420+
421+
it('should select the combobox item instead of adding raw input text as tag', async () => {
422+
// Focus input and type "a" to filter
423+
input.element.focus()
424+
await input.setValue('a')
425+
await nextTick()
426+
427+
// Simulate the blur that happens when clicking an option (jsdom doesn't do this automatically)
428+
const option = wrapper.find('[role=option]')
429+
expect(option.text()).toContain('Apple')
430+
431+
// In a real browser: mousedown on option → input blurs → click fires
432+
await input.trigger('blur', { relatedTarget: option.element })
433+
await option.trigger('click')
434+
await nextTick()
435+
436+
// "Apple" should be added as tag, NOT the raw text "a"
437+
const tags = wrapper.findAll('[data-reka-collection-item]')
438+
const tagTexts = tags.map(t => t.text())
439+
expect(tagTexts).toContain('Apple')
440+
expect(tagTexts).not.toContain('a')
441+
})
442+
443+
it('should refocus input after selecting in multiple mode', async () => {
444+
// Focus input and open dropdown
445+
input.element.focus()
446+
await input.setValue('a')
447+
await nextTick()
448+
449+
const option = wrapper.find('[role=option]')
450+
451+
// In a real browser, mousedown on option blurs the input.
452+
// Simulate this: blur the input, then click the option.
453+
input.element.blur()
454+
await option.trigger('click')
455+
await nextTick()
456+
457+
// Input should be refocused so subsequent blur can trigger addOnBlur
458+
expect(document.activeElement).toBe(input.element)
459+
})
460+
})

packages/core/src/Combobox/ComboboxItem.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ onUnmounted(() => {
9191
rootContext.onOpenChange(false)
9292
rootContext.modelValue.value = props.value
9393
}
94+
else if (rootContext.multiple.value) {
95+
rootContext.inputElement.value?.focus()
96+
}
9497
}"
9598
>
9699
<slot>{{ value }}</slot>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from 'vue'
3+
import { useFilter } from '@/shared'
4+
import { TagsInputInput, TagsInputItem, TagsInputItemText, TagsInputRoot } from '@/TagsInput'
5+
import { ComboboxAnchor, ComboboxContent, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxRoot, ComboboxViewport } from '..'
6+
7+
const props = withDefaults(defineProps<{
8+
addOnBlur?: boolean
9+
}>(), {
10+
addOnBlur: false,
11+
})
12+
13+
const { contains } = useFilter({ sensitivity: 'base' })
14+
15+
const query = ref('')
16+
const values = ref<string[]>([])
17+
const options = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
18+
19+
const filteredOptions = computed(() => options.filter(option => contains(option, query.value) && !values.value.includes(option)))
20+
</script>
21+
22+
<template>
23+
<ComboboxRoot
24+
v-model="values"
25+
multiple
26+
>
27+
<ComboboxAnchor>
28+
<TagsInputRoot
29+
v-slot="{ modelValue: tags }"
30+
v-model="values"
31+
:add-on-blur="props.addOnBlur"
32+
delimiter=""
33+
>
34+
<TagsInputItem
35+
v-for="(item, index) in tags"
36+
:key="index"
37+
:value="item"
38+
>
39+
<TagsInputItemText />
40+
</TagsInputItem>
41+
42+
<ComboboxInput
43+
v-model="query"
44+
as-child
45+
>
46+
<TagsInputInput
47+
placeholder="Fruits..."
48+
@keydown.enter.prevent
49+
/>
50+
</ComboboxInput>
51+
</TagsInputRoot>
52+
</ComboboxAnchor>
53+
<ComboboxContent>
54+
<ComboboxViewport>
55+
<ComboboxGroup>
56+
<ComboboxItem
57+
v-for="(option, index) in filteredOptions"
58+
:key="index"
59+
:value="option"
60+
>
61+
<span>{{ option }}</span>
62+
</ComboboxItem>
63+
</ComboboxGroup>
64+
</ComboboxViewport>
65+
</ComboboxContent>
66+
</ComboboxRoot>
67+
</template>

packages/core/src/TagsInput/TagsInputInput.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,22 @@ const props = withDefaults(defineProps<TagsInputInputProps>(), {
2424
const context = injectTagsInputRootContext()
2525
const { forwardRef, currentElement } = useForwardExpose()
2626
27-
function handleBlur(event: Event) {
27+
function handleBlur(event: FocusEvent) {
2828
context.selectedElement.value = undefined
2929
3030
if (!context.addOnBlur.value)
3131
return
3232
3333
const target = event.target as HTMLInputElement
34+
35+
// If the blur is caused by clicking an option within the content,
36+
// we don't trigger the `addOnBlur` action,
37+
// because the clicked option should be added instead of the input's current value.
38+
const relatedTarget = event.relatedTarget as HTMLElement | null
39+
const controlledId = target.getAttribute('aria-controls')
40+
if (controlledId && relatedTarget?.closest(`#${CSS.escape(controlledId)}`))
41+
return
42+
3443
if (!target.value)
3544
return
3645

0 commit comments

Comments
 (0)