Skip to content

Commit 00fdb7e

Browse files
authored
Ensure IME works on Android devices (#2580)
* simplify `isComposing` We had an issue #2409 where typing using an IME (Input Method Editor, E.g.: Japanese — Romaji) already submitted characters while the user was still composing the characters together. 1. Type `wa` 2. Expected result: `わ` 3. Actual result: `wあ` (where `あ` is the character `a`) This was solved by not triggering change events at all until the `compositionend` event was triggered. This worked fine for this use case. However this also meant that only at the end of your typing session (when you press `enter`/`space`) the actual value was submitted. Fast forward to today, we received a new issue #2575 where this behaviour completely broke on Android devices. Android _always_ use the IME APIs for handling input... if we think about our solution form above, it also means that while you are typing on an Android device no options are being filtered at all. The moment you hit enter/space the combobox will open and results will be filtered. This is where this fix comes in. The goals are simple: 1. Make it work 2. Try to make the current code simpler I started digging to see _why_ this `wあ` was even submitted. A normal input field doesn't do that?! We have some code that does the following things: 1. Sync the selected value with the `input` such that if you update the value from the outside, then the value in the `input` is up-to-date with the `displayValue` of that incoming value. 2. A fix for macOS VoiceOver to improve the VoiceOver experience when opening the `Combobox` component. This is done by manually resetting the value of the `input` field. 1. Keep track of the current value 2. Keep track of the current selection range (start/end) state 3. Reset the input to an empty string `''` 4. Restore the value to the captured value 5. Restore the selection range When you are typing, the input field doesn't have to update yet because typing doesn't cause an option to become the `selected` option, therefore it doesn't have to sync the value yet. So (1.) isn't the issue here. However, when you start typing, the Combobox should open and then we trigger the macOS VoiceOver fix. This is touching the `input` field because we change the value & selection. Because we touched the `input` while the user was still in a composing mode, it bailed and submitted whatever characters it had. This is the part that we don't want. Not applying the macOS VoiceOver fix while typing solves this issue. In addition, because _we_ are touching the input field, VoiceOver is acting normally. In hindsight, the solution is very simple: do not touch the input field when the user is typing. We still keep track whether the user `isComposing` so that we can bail on the default `Enter` behaviour (marking the current option as the selected option) because pressing `Enter` while composing should get out of the IME. Fixes: #2575 * update changelog
1 parent 17b2d34 commit 00fdb7e

File tree

4 files changed

+42
-26
lines changed

4 files changed

+42
-26
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Ensure the caret is in a consistent position when syncing the `Combobox.Input` value ([#2568](https://github.com/tailwindlabs/headlessui/pull/2568))
1313
- Improve "outside click" behaviour in combination with 3rd party libraries ([#2572](https://github.com/tailwindlabs/headlessui/pull/2572))
14+
- Ensure IME works on Android devices ([#2580](https://github.com/tailwindlabs/headlessui/pull/2580))
1415

1516
## [1.7.15] - 2023-06-01
1617

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -778,9 +778,11 @@ function InputFn<
778778
// - By clicking `outside` of the Combobox
779779
useWatch(
780780
([currentDisplayValue, state], [oldCurrentDisplayValue, oldState]) => {
781+
// When the user is typing, we want to not touch the `input` at all. Especially when they are
782+
// using an IME, we don't want to mess with the input at all.
781783
if (isTyping.current) return
782-
let input = data.inputRef.current
783784

785+
let input = data.inputRef.current
784786
if (!input) return
785787

786788
if (oldState === ComboboxState.Open && state === ComboboxState.Closed) {
@@ -821,6 +823,10 @@ function InputFn<
821823
useWatch(
822824
([newState], [oldState]) => {
823825
if (newState === ComboboxState.Open && oldState === ComboboxState.Closed) {
826+
// When the user is typing, we want to not touch the `input` at all. Especially when they are
827+
// using an IME, we don't want to mess with the input at all.
828+
if (isTyping.current) return
829+
824830
let input = data.inputRef.current
825831
if (!input) return
826832

@@ -844,19 +850,12 @@ function InputFn<
844850
)
845851

846852
let isComposing = useRef(false)
847-
let composedChangeEvent = useRef<React.ChangeEvent<HTMLInputElement> | null>(null)
848853
let handleCompositionStart = useEvent(() => {
849854
isComposing.current = true
850855
})
851856
let handleCompositionEnd = useEvent(() => {
852857
d.nextFrame(() => {
853858
isComposing.current = false
854-
855-
if (composedChangeEvent.current) {
856-
actions.openCombobox()
857-
onChange?.(composedChangeEvent.current)
858-
composedChangeEvent.current = null
859-
}
860859
})
861860
})
862861

@@ -885,6 +884,10 @@ function InputFn<
885884
case Keys.Enter:
886885
isTyping.current = false
887886
if (data.comboboxState !== ComboboxState.Open) return
887+
888+
// When the user is still in the middle of composing by using an IME, then we don't want to
889+
// submit this value and close the Combobox yet. Instead, we will fallback to the default
890+
// behaviour which is to "end" the composition.
888891
if (isComposing.current) return
889892

890893
event.preventDefault()
@@ -983,12 +986,16 @@ function InputFn<
983986
})
984987

985988
let handleChange = useEvent((event: React.ChangeEvent<HTMLInputElement>) => {
986-
if (isComposing.current) {
987-
composedChangeEvent.current = event
988-
return
989-
}
990-
actions.openCombobox()
989+
// Always call the onChange listener even if the user is still typing using an IME (Input Method
990+
// Editor).
991+
//
992+
// The main issue is Android, where typing always uses the IME APIs. Just waiting until the
993+
// compositionend event is fired to trigger an onChange is not enough, because then filtering
994+
// options while typing won't work at all because we are still in "composing" mode.
991995
onChange?.(event)
996+
997+
// Open the combobox to show the results based on what the user has typed
998+
actions.openCombobox()
992999
})
9931000

9941001
let handleBlur = useEvent(() => {

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Ensure the caret is in a consistent position when syncing the `Combobox.Input` value ([#2568](https://github.com/tailwindlabs/headlessui/pull/2568))
1313
- Improve "outside click" behaviour in combination with 3rd party libraries ([#2572](https://github.com/tailwindlabs/headlessui/pull/2572))
1414
- Improve performance of `Combobox` component ([#2574](https://github.com/tailwindlabs/headlessui/pull/2574))
15+
- Ensure IME works on Android devices ([#2580](https://github.com/tailwindlabs/headlessui/pull/2580))
1516

1617
## [1.7.14] - 2023-06-01
1718

packages/@headlessui-vue/src/components/combobox/combobox.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -745,9 +745,11 @@ export let ComboboxInput = defineComponent({
745745
watch(
746746
[currentDisplayValue, api.comboboxState],
747747
([currentDisplayValue, state], [oldCurrentDisplayValue, oldState]) => {
748+
// When the user is typing, we want to not touch the `input` at all. Especially when they
749+
// are using an IME, we don't want to mess with the input at all.
748750
if (isTyping.value) return
749-
let input = dom(api.inputRef)
750751

752+
let input = dom(api.inputRef)
751753
if (!input) return
752754

753755
if (oldState === ComboboxStates.Open && state === ComboboxStates.Closed) {
@@ -788,6 +790,10 @@ export let ComboboxInput = defineComponent({
788790
// already in an open state.
789791
watch([api.comboboxState], ([newState], [oldState]) => {
790792
if (newState === ComboboxStates.Open && oldState === ComboboxStates.Closed) {
793+
// When the user is typing, we want to not touch the `input` at all. Especially when they
794+
// are using an IME, we don't want to mess with the input at all.
795+
if (isTyping.value) return
796+
791797
let input = dom(api.inputRef)
792798
if (!input) return
793799

@@ -810,19 +816,12 @@ export let ComboboxInput = defineComponent({
810816
})
811817

812818
let isComposing = ref(false)
813-
let composedChangeEvent = ref<(Event & { target: HTMLInputElement }) | null>(null)
814819
function handleCompositionstart() {
815820
isComposing.value = true
816821
}
817822
function handleCompositionend() {
818823
disposables().nextFrame(() => {
819824
isComposing.value = false
820-
821-
if (composedChangeEvent.value) {
822-
api.openCombobox()
823-
emit('change', composedChangeEvent.value)
824-
composedChangeEvent.value = null
825-
}
826825
})
827826
}
828827

@@ -852,6 +851,10 @@ export let ComboboxInput = defineComponent({
852851
case Keys.Enter:
853852
isTyping.value = false
854853
if (api.comboboxState.value !== ComboboxStates.Open) return
854+
855+
// When the user is still in the middle of composing by using an IME, then we don't want
856+
// to submit this value and close the Combobox yet. Instead, we will fallback to the
857+
// default behaviour which is to "end" the composition.
855858
if (isComposing.value) return
856859

857860
event.preventDefault()
@@ -945,12 +948,16 @@ export let ComboboxInput = defineComponent({
945948
}
946949

947950
function handleInput(event: Event & { target: HTMLInputElement }) {
948-
if (isComposing.value) {
949-
composedChangeEvent.value = event
950-
return
951-
}
952-
api.openCombobox()
951+
// Always call the onChange listener even if the user is still typing using an IME (Input Method
952+
// Editor).
953+
//
954+
// The main issue is Android, where typing always uses the IME APIs. Just waiting until the
955+
// compositionend event is fired to trigger an onChange is not enough, because then filtering
956+
// options while typing won't work at all because we are still in "composing" mode.
953957
emit('change', event)
958+
959+
// Open the combobox to show the results based on what the user has typed
960+
api.openCombobox()
954961
}
955962

956963
function handleBlur() {

0 commit comments

Comments
 (0)