Skip to content

Commit 8212fa5

Browse files
fix: handle IME composition correctly across input components (#2665)
* fix: handle IME composition correctly across input components CJK users experienced broken input in PinInput, DateField, TagsInput, Combobox, and filter components because intermediate IME composition events were processed as final input. This caused premature focus shifts, corrupted segments, and bypassed validation. Introduces a shared `useComposing` composable and adds `isComposing` guards to all affected components so that only the committed value from `compositionend` is processed. * fix: tighten test assertions and handle multi-char PinInput composition - Combobox test: replace vacuous conditional assertion with deterministic check - DropdownMenuFilter test: use exact baseline count instead of toBeGreaterThan(0) - PinInput: route multi-char composition commits through handleMultipleCharacter so characters distribute across slots like normal multi-char input does - Add tests for multi-char and multi-digit composition commits in PinInput * fix: address maintainer review on IME composition - ComboboxInput/AutocompleteInput: drop `.prevent` modifier and call `preventDefault()` inside the handler only when not composing, so IME candidate navigation with arrow keys isn't swallowed mid-composition - ListboxFilter: defer modelValue update to compositionend so consumer-side filtering no longer reacts to intermediate text (matches Combobox/DropdownMenu) - useDateField: dispatch replayed composition digits to the focused element so multi-digit commits follow focus across segments (e.g. "45" -> 4 in month, 5 in day) instead of all landing in the original segment - fix `iME` -> `IME` typo in test describe blocks - add multi-digit cross-segment DateField composition test * test: reword composition describe titles so IME keeps casing The `prefer-lowercase-title` lint rule lowercases the first character of each describe title, turning `IME` into `iME`. Reword to `handle IME composition` so the acronym sits mid-title and keeps its casing. * fix: harden IME composition handling across input components Guard segment navigation and direct typing in Date/Time fields while a CJK IME is active: block raw text insertion via beforeinput, restore Vue's original contenteditable nodes on compositionend, and stop root arrow navigation during composition. Route NumberField, ColorField and TagsInput keydown through useComposing so the composing flag stays true through the commit keydown (which can report event.isComposing === false), preventing the commit keypress from also stepping/applying the field or navigating tags.
1 parent eeac4e6 commit 8212fa5

28 files changed

Lines changed: 828 additions & 58 deletions

packages/core/src/Autocomplete/Autocomplete.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ describe('given default Autocomplete', () => {
133133
})
134134
})
135135

136-
describe('iME composition', () => {
136+
describe('handle IME composition', () => {
137137
it('should not filter during composition', async () => {
138138
await input.trigger('compositionstart')
139139
input.element.value = 'x'

packages/core/src/Autocomplete/AutocompleteInput.vue

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ export interface AutocompleteInputProps extends ListboxFilterProps {}
77

88
<script setup lang="ts">
99
import { useVModel } from '@vueuse/core'
10-
import { nextTick, onMounted, ref, watch } from 'vue'
10+
import { nextTick, onMounted, watch } from 'vue'
1111
import { injectComboboxRootContext } from '@/Combobox/ComboboxRoot.vue'
1212
import { ListboxFilter } from '@/Listbox'
1313
import { injectListboxRootContext } from '@/Listbox/ListboxRoot.vue'
1414
import { usePrimitiveElement } from '@/Primitive'
15+
import { useComposing } from '@/shared'
1516
import { injectAutocompleteRootContext } from './AutocompleteRoot.vue'
1617
1718
const props = withDefaults(defineProps<AutocompleteInputProps>(), {
@@ -37,22 +38,17 @@ onMounted(() => {
3738
rootContext.onInputElementChange(currentElement.value as HTMLInputElement)
3839
})
3940
40-
const isComposing = ref(false)
41-
function onCompositionStart() {
42-
isComposing.value = true
43-
}
44-
function onCompositionEnd() {
45-
nextTick(() => {
46-
isComposing.value = false
47-
const el = currentElement.value as HTMLInputElement
48-
if (el)
49-
processInputValue(el.value)
50-
})
51-
}
41+
const { isComposing, handleCompositionStart, handleCompositionEnd } = useComposing((event) => {
42+
const el = event.target as HTMLInputElement
43+
if (el)
44+
processInputValue(el.value)
45+
})
5246
53-
function handleKeyDown(_ev: KeyboardEvent) {
47+
function handleKeyDown(ev: KeyboardEvent) {
48+
// Don't swallow arrow keys mid-composition, they're used for IME candidate navigation
5449
if (isComposing.value)
5550
return
51+
ev.preventDefault()
5652
if (!rootContext.open.value)
5753
rootContext.onOpenChange(true)
5854
}
@@ -124,10 +120,10 @@ watch(rootContext.filterState, (_newValue, oldValue) => {
124120
autocomplete="off"
125121
@click="handleClick"
126122
@input="handleInput"
127-
@keydown.down.up.prevent="handleKeyDown"
123+
@keydown.down.up="handleKeyDown"
128124
@focus="handleFocus"
129-
@compositionstart="onCompositionStart"
130-
@compositionend="onCompositionEnd"
125+
@compositionstart="handleCompositionStart"
126+
@compositionend="handleCompositionEnd"
131127
>
132128
<slot />
133129
</ListboxFilter>

packages/core/src/ColorField/ColorField.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,43 @@ describe('keyboard interactions', () => {
338338
await input.trigger('keydown', { key: 'ArrowUp' })
339339
expect(input.element.value).toBe('0')
340340
})
341+
342+
it('should not block beforeinput during IME composition', async () => {
343+
const wrapper = mount(ColorField, {
344+
props: {
345+
defaultValue: '#ff0000',
346+
channel: 'hue',
347+
colorSpace: 'hsl',
348+
},
349+
})
350+
const input = wrapper.find('input')
351+
const event = new InputEvent('beforeinput', {
352+
data: 'あ',
353+
cancelable: true,
354+
// @ts-expect-error InputEvent doesn't expose isComposing in constructor but it's set on the event
355+
})
356+
Object.defineProperty(event, 'isComposing', { value: true })
357+
input.element.dispatchEvent(event)
358+
expect(event.defaultPrevented).toBe(false)
359+
})
360+
361+
it('should not step the value during composition (arrow keys are IME candidate navigation)', async () => {
362+
const wrapper = mount(ColorField, {
363+
props: {
364+
defaultValue: '#ff0000',
365+
channel: 'hue',
366+
colorSpace: 'hsl',
367+
},
368+
})
369+
const input = wrapper.find('input')
370+
expect(input.element.value).toBe('0')
371+
372+
// Arrow keys mid-composition navigate IME candidates, they must not step the value
373+
await input.trigger('keydown', { key: 'ArrowUp', isComposing: true })
374+
expect(input.element.value).toBe('0')
375+
376+
// Once composition ends, stepping works again
377+
await input.trigger('keydown', { key: 'ArrowUp' })
378+
expect(input.element.value).toBe('1')
379+
})
341380
})

packages/core/src/ColorField/ColorFieldInput.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface ColorFieldInputProps extends PrimitiveProps {}
99
<script setup lang="ts">
1010
import { computed, ref } from 'vue'
1111
import { Primitive } from '@/Primitive'
12+
import { useComposing } from '@/shared'
1213
import { injectColorFieldRootContext } from './ColorFieldRoot.vue'
1314
1415
const props = withDefaults(defineProps<ColorFieldInputProps>(), {
@@ -18,6 +19,7 @@ const props = withDefaults(defineProps<ColorFieldInputProps>(), {
1819
const rootContext = injectColorFieldRootContext()
1920
2021
const isFocused = ref(false)
22+
const { isComposing, handleCompositionStart, handleCompositionEnd } = useComposing()
2123
2224
const inputType = computed(() => {
2325
return rootContext.channel.value ? 'text' : 'text'
@@ -48,6 +50,11 @@ function handleWheel(event: WheelEvent) {
4850
}
4951
5052
function handleKeydown(event: KeyboardEvent) {
53+
// Don't step/commit mid-composition, keys are used for IME candidate navigation and commit.
54+
// `isComposing` stays true until the tick after `compositionend`, so the commit keydown
55+
// (which can report `event.isComposing === false`) is still skipped.
56+
if (isComposing.value || event.isComposing)
57+
return
5158
switch (event.key) {
5259
case 'ArrowUp':
5360
event.preventDefault()
@@ -82,6 +89,8 @@ function handleKeydown(event: KeyboardEvent) {
8289
8390
// Handle numeric key validation for channel mode
8491
function handleBeforeInput(event: InputEvent) {
92+
if (event.isComposing)
93+
return
8594
if (!rootContext.channel.value)
8695
return // No validation for hex mode
8796
@@ -129,6 +138,8 @@ function handleBeforeInput(event: InputEvent) {
129138
@keydown="handleKeydown"
130139
@wheel="handleWheel"
131140
@beforeinput="handleBeforeInput"
141+
@compositionstart="handleCompositionStart"
142+
@compositionend="handleCompositionEnd"
132143
>
133144
<slot />
134145
</Primitive>

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,54 @@ describe('given Combobox with TagsInput and addOnBlur', () => {
480480
})
481481
})
482482

483+
describe('handle IME composition', () => {
484+
let wrapper: VueWrapper<InstanceType<typeof Combobox>>
485+
let input: DOMWrapper<HTMLInputElement>
486+
window.HTMLElement.prototype.releasePointerCapture = vi.fn()
487+
window.HTMLElement.prototype.hasPointerCapture = vi.fn()
488+
window.HTMLElement.prototype.scrollIntoView = vi.fn()
489+
globalThis.ResizeObserver = class ResizeObserver {
490+
observe() {}
491+
unobserve() {}
492+
disconnect() {}
493+
}
494+
495+
beforeEach(() => {
496+
// @ts-expect-error aXe throwing error complaining getComputedStyle
497+
window.getComputedStyle = () => ({
498+
animationName: '',
499+
})
500+
document.body.innerHTML = ''
501+
wrapper = mount(Combobox, { attachTo: document.body })
502+
input = wrapper.find('input')
503+
})
504+
505+
it('should not update filter during IME composition', async () => {
506+
await input.trigger('compositionstart')
507+
input.element.value = 'xiang'
508+
await input.trigger('input')
509+
await nextTick()
510+
511+
const content = wrapper.find('[role=listbox]')
512+
expect(content.exists()).toBe(false)
513+
})
514+
515+
it('should update filter after composition ends', async () => {
516+
await input.trigger('compositionstart')
517+
input.element.value = 'zzzzz'
518+
await input.trigger('input')
519+
await nextTick()
520+
521+
input.element.value = 'zzzzz'
522+
await input.trigger('compositionend')
523+
await nextTick()
524+
525+
const content = wrapper.find('[role=listbox]')
526+
expect(content.exists()).toBe(true)
527+
expect(content.attributes('data-empty')).toBeDefined()
528+
})
529+
})
530+
483531
describe('given combobox handleBlur with deferred close', () => {
484532
let wrapper: VueWrapper<InstanceType<typeof Combobox>>
485533

packages/core/src/Combobox/ComboboxInput.vue

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ListboxFilterEmits, ListboxFilterProps } from '@/Listbox'
33
import { useVModel } from '@vueuse/core'
44
import { nextTick, onMounted, watch } from 'vue'
55
import { usePrimitiveElement } from '@/Primitive'
6+
import { useComposing } from '@/shared'
67
78
export type ComboboxInputEmits = ListboxFilterEmits
89
export interface ComboboxInputProps extends ListboxFilterProps {
@@ -34,27 +35,42 @@ onMounted(() => {
3435
rootContext.onInputElementChange(currentElement.value as HTMLInputElement)
3536
})
3637
38+
const { isComposing, handleCompositionStart, handleCompositionEnd } = useComposing((event) => {
39+
const el = event.target as HTMLInputElement
40+
if (el)
41+
processInputValue(el.value)
42+
})
43+
3744
function handleKeyDown(ev: KeyboardEvent) {
45+
// Don't swallow arrow keys mid-composition, they're used for IME candidate navigation
46+
if (isComposing.value)
47+
return
48+
ev.preventDefault()
3849
if (!rootContext.open.value)
3950
rootContext.onOpenChange(true)
4051
}
4152
42-
function handleInput(event: InputEvent) {
43-
const target = event.target as HTMLInputElement
53+
function processInputValue(value: string) {
4454
if (!rootContext.open.value) {
4555
rootContext.onOpenChange(true)
4656
nextTick(() => {
47-
if (target.value) {
48-
rootContext.filterSearch.value = target.value
57+
if (value) {
58+
rootContext.filterSearch.value = value
4959
listboxContext.highlightFirstItem()
5060
}
5161
})
5262
}
5363
else {
54-
rootContext.filterSearch.value = target.value
64+
rootContext.filterSearch.value = value
5565
}
5666
}
5767
68+
function handleInput(event: InputEvent) {
69+
if (isComposing.value)
70+
return
71+
processInputValue((event.target as HTMLInputElement).value)
72+
}
73+
5874
function handleFocus() {
5975
if (rootContext.openOnFocus.value && !rootContext.open.value)
6076
rootContext.onOpenChange(true)
@@ -150,9 +166,11 @@ watch(rootContext.filterState, (_newValue, oldValue) => {
150166
autocomplete="off"
151167
@click="handleClick"
152168
@input="handleInput"
153-
@keydown.down.up.prevent="handleKeyDown"
169+
@keydown.down.up="handleKeyDown"
154170
@focus="handleFocus"
155171
@blur="handleBlur"
172+
@compositionstart="handleCompositionStart"
173+
@compositionend="handleCompositionEnd"
156174
>
157175
<slot />
158176
</ListboxFilter>

0 commit comments

Comments
 (0)