Skip to content

Commit 7f5ba08

Browse files
fix: stop processing input when composing with an IME (#1226)
* fix: stop processing input when composing with an IME * add test * prevent processing keydown if composition is in progress * fix: make it work for both React and default renderer --------- Co-authored-by: Aymeric Giraudet <aymeric.giraudet@algolia.com>
1 parent bb80dbb commit 7f5ba08

File tree

7 files changed

+164
-1
lines changed

7 files changed

+164
-1
lines changed

packages/autocomplete-core/src/__tests__/getInputProps.test.ts

+131-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { waitFor } from '@testing-library/dom';
1+
import { fireEvent, waitFor } from '@testing-library/dom';
22
import userEvent from '@testing-library/user-event';
33

44
import {
@@ -645,6 +645,66 @@ describe('getInputProps', () => {
645645

646646
expect(environment.clearTimeout).toHaveBeenLastCalledWith(999);
647647
});
648+
649+
test('stops process if IME composition is in progress', () => {
650+
const getSources = jest.fn((..._args: any[]) => {
651+
return [
652+
createSource({
653+
getItems() {
654+
return [{ label: '1' }, { label: '2' }];
655+
},
656+
}),
657+
];
658+
});
659+
const { inputElement } = createPlayground(createAutocomplete, {
660+
getSources,
661+
});
662+
663+
// Typing 木 using the Wubihua input method
664+
// see:
665+
// - https://en.wikipedia.org/wiki/Stroke_count_method
666+
// - https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event
667+
const character = '木';
668+
const strokes = ['一', '丨', '丿', '丶', character];
669+
670+
strokes.forEach((stroke, index) => {
671+
const isFirst = index === 0;
672+
const isLast = index === strokes.length - 1;
673+
const query = isLast ? stroke : strokes.slice(0, index + 1).join('');
674+
675+
if (isFirst) {
676+
fireEvent.compositionStart(inputElement);
677+
}
678+
679+
fireEvent.compositionUpdate(inputElement, {
680+
data: query,
681+
});
682+
683+
fireEvent.input(inputElement, {
684+
isComposing: true,
685+
target: {
686+
value: query,
687+
},
688+
});
689+
690+
if (isLast) {
691+
fireEvent.compositionEnd(inputElement, {
692+
data: query,
693+
target: {
694+
value: query,
695+
},
696+
});
697+
}
698+
});
699+
700+
expect(inputElement).toHaveValue(character);
701+
expect(getSources).toHaveBeenCalledTimes(1);
702+
expect(getSources).toHaveBeenLastCalledWith(
703+
expect.objectContaining({
704+
query: character,
705+
})
706+
);
707+
});
648708
});
649709

650710
describe('onKeyDown', () => {
@@ -1913,6 +1973,76 @@ describe('getInputProps', () => {
19131973
);
19141974
});
19151975
});
1976+
1977+
test('stops process if IME is in progress', () => {
1978+
const onStateChange = jest.fn();
1979+
const { inputElement } = createPlayground(createAutocomplete, {
1980+
openOnFocus: true,
1981+
onStateChange,
1982+
initialState: {
1983+
collections: [
1984+
createCollection({
1985+
source: { sourceId: 'testSource' },
1986+
items: [
1987+
{ label: '1' },
1988+
{ label: '2' },
1989+
{ label: '3' },
1990+
{ label: '4' },
1991+
],
1992+
}),
1993+
],
1994+
},
1995+
});
1996+
1997+
inputElement.focus();
1998+
1999+
// 1. Pressing Arrow Down to select the first item
2000+
fireEvent.keyDown(inputElement, { key: 'ArrowDown' });
2001+
expect(onStateChange).toHaveBeenLastCalledWith(
2002+
expect.objectContaining({
2003+
state: expect.objectContaining({
2004+
activeItemId: 0,
2005+
}),
2006+
})
2007+
);
2008+
2009+
// 2. Typing かくてい with a Japanese IME
2010+
const strokes = ['か', 'く', 'て', 'い'];
2011+
strokes.forEach((_stroke, index) => {
2012+
const isFirst = index === 0;
2013+
const query = strokes.slice(0, index + 1).join('');
2014+
2015+
if (isFirst) {
2016+
fireEvent.compositionStart(inputElement);
2017+
}
2018+
2019+
fireEvent.compositionUpdate(inputElement, {
2020+
data: query,
2021+
});
2022+
2023+
fireEvent.input(inputElement, {
2024+
isComposing: true,
2025+
data: query,
2026+
target: {
2027+
value: query,
2028+
},
2029+
});
2030+
});
2031+
2032+
// 3. Selecting the 3rd suggestion on the IME window
2033+
fireEvent.keyDown(inputElement, { key: 'ArrowDown', isComposing: true });
2034+
fireEvent.keyDown(inputElement, { key: 'ArrowDown', isComposing: true });
2035+
fireEvent.keyDown(inputElement, { key: 'ArrowDown', isComposing: true });
2036+
2037+
// 4. Checking that activeItemId has not changed
2038+
expect(onStateChange).toHaveBeenLastCalledWith(
2039+
expect.objectContaining({
2040+
state: expect.objectContaining({
2041+
activeItemId: 0,
2042+
}),
2043+
})
2044+
);
2045+
});
19162046
});
19172047

19182048
describe('onFocus', () => {

packages/autocomplete-core/src/getPropGetters.ts

+24
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
getAutocompleteElementId,
2222
isOrContainsNode,
2323
isSamsung,
24+
getNativeEvent,
2425
} from './utils';
2526

2627
interface GetPropGettersOptions<TItem extends BaseItem>
@@ -219,6 +220,25 @@ export function getPropGetters<
219220
maxLength,
220221
type: 'search',
221222
onChange: (event) => {
223+
const value = (
224+
(event as unknown as Event).currentTarget as HTMLInputElement
225+
).value;
226+
227+
if (getNativeEvent(event as unknown as InputEvent).isComposing) {
228+
setters.setQuery(value);
229+
return;
230+
}
231+
232+
onInput({
233+
event,
234+
props,
235+
query: value.slice(0, maxLength),
236+
refresh,
237+
store,
238+
...setters,
239+
});
240+
},
241+
onCompositionEnd: (event) => {
222242
onInput({
223243
event,
224244
props,
@@ -231,6 +251,10 @@ export function getPropGetters<
231251
});
232252
},
233253
onKeyDown: (event) => {
254+
if (getNativeEvent(event as unknown as InputEvent).isComposing) {
255+
return;
256+
}
257+
234258
onKeyDown({
235259
event: event as unknown as KeyboardEvent,
236260
props,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function getNativeEvent<TEvent>(event: TEvent) {
2+
return (event as unknown as { nativeEvent: TEvent }).nativeEvent || event;
3+
}

packages/autocomplete-core/src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * from './getAutocompleteElementId';
88
export * from './isOrContainsNode';
99
export * from './isSamsung';
1010
export * from './mapToAlgoliaResponse';
11+
export * from './getNativeEvent';

packages/autocomplete-js/src/utils/setProperties.ts

+3
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ function getNormalizedName(name: string): string {
110110
switch (name) {
111111
case 'onChange':
112112
return 'onInput';
113+
// see: https://github.com/preactjs/preact/issues/1978
114+
case 'onCompositionEnd':
115+
return 'oncompositionend';
113116
default:
114117
return name;
115118
}

packages/autocomplete-shared/src/core/AutocompletePropGetters.ts

+1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export type GetInputProps<TEvent, TMouseEvent, TKeyboardEvent> = (props: {
8383
'aria-controls': string | undefined;
8484
'aria-labelledby': string;
8585
onChange(event: TEvent): void;
86+
onCompositionEnd(event: TEvent): void;
8687
onKeyDown(event: TKeyboardEvent): void;
8788
onFocus(event: TEvent): void;
8889
onBlur(): void;

test/utils/createPlayground.ts

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function createPlayground<TItem extends Record<string, unknown>>(
2424
const formProps = autocomplete.getFormProps({ inputElement });
2525
inputElement.addEventListener('blur', inputProps.onBlur);
2626
inputElement.addEventListener('input', inputProps.onChange);
27+
inputElement.addEventListener('compositionend', inputProps.onCompositionEnd);
2728
inputElement.addEventListener('click', inputProps.onClick);
2829
inputElement.addEventListener('focus', inputProps.onFocus);
2930
inputElement.addEventListener('keydown', inputProps.onKeyDown);

0 commit comments

Comments
 (0)