fix(search): wait for committed IME input before searching

This commit is contained in:
chihao.ou
2026-09-19 19:43:08 +08:00
parent 1a9d49e16f
commit 7bf3d0b585
2 changed files with 58 additions and 7 deletions
@@ -4,16 +4,16 @@
<Icon name="search" size="md" class="text-gray-400" />
</div>
<input
:value="modelValue"
v-model="searchValue"
type="text"
class="input pl-10"
:placeholder="placeholder"
@input="handleInput"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import Icon from '@/components/icons/Icon.vue'
@@ -35,9 +35,11 @@ const debouncedEmitSearch = useDebounceFn((value: string) => {
emit('search', value)
}, props.debounceMs)
const handleInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
debouncedEmitSearch(value)
}
const searchValue = computed({
get: () => props.modelValue,
set: (value: string) => {
emit('update:modelValue', value)
debouncedEmitSearch(value)
}
})
</script>
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import SearchInput from '../SearchInput.vue'
enableAutoUnmount(afterEach)
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
function mountInput() {
return mount(SearchInput, { props: { modelValue: '', debounceMs: 300 }, global: { stubs: { Icon: true } } })
}
describe('SearchInput composition', () => {
it('waits for committed IME text before updating or searching', async () => {
const wrapper = mountInput()
const input = wrapper.get('input')
await input.trigger('compositionstart')
input.element.value = 'zhong'
await input.trigger('input')
await vi.advanceTimersByTimeAsync(500)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.emitted('search')).toBeUndefined()
input.element.value = '中文'
await input.trigger('compositionend')
expect(wrapper.emitted('update:modelValue')).toEqual([['中文']])
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toEqual([['中文']])
})
it('still updates plain text immediately and debounces searches', async () => {
const wrapper = mountInput()
const input = wrapper.get('input')
await input.setValue('a')
await vi.advanceTimersByTimeAsync(200)
await input.setValue('ab')
expect(wrapper.emitted('update:modelValue')).toEqual([['a'], ['ab']])
await vi.advanceTimersByTimeAsync(299)
expect(wrapper.emitted('search')).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(wrapper.emitted('search')).toEqual([['ab']])
})
it('reflects external value changes without issuing a search', async () => {
const wrapper = mountInput()
await wrapper.setProps({ modelValue: 'restored' })
expect(wrapper.get('input').element.value).toBe('restored')
await vi.advanceTimersByTimeAsync(500)
expect(wrapper.emitted('search')).toBeUndefined()
})
})