Skip to content

Commit

Permalink
fix: ignore composition events with option (#1238)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Aymeric Giraudet <aymeric.giraudet@algolia.com>
  • Loading branch information
dhayab and aymeric-giraudet authored Jan 29, 2024
1 parent 190e562 commit fba16e5
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 1 deletion.
142 changes: 141 additions & 1 deletion packages/autocomplete-core/src/__tests__/getInputProps.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { waitFor } from '@testing-library/dom';
import { fireEvent, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';

import {
Expand Down Expand Up @@ -645,6 +645,67 @@ describe('getInputProps', () => {

expect(environment.clearTimeout).toHaveBeenLastCalledWith(999);
});

test('stops process if IME composition is in progress and `ignoreCompositionEvents: true`', () => {
const getSources = jest.fn((..._args: any[]) => {
return [
createSource({
getItems() {
return [{ label: '1' }, { label: '2' }];
},
}),
];
});
const { inputElement } = createPlayground(createAutocomplete, {
ignoreCompositionEvents: true,
getSources,
});

// Typing 木 using the Wubihua input method
// see:
// - https://en.wikipedia.org/wiki/Stroke_count_method
// - https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event
const character = '木';
const strokes = ['一', '丨', '丿', '丶', character];

strokes.forEach((stroke, index) => {
const isFirst = index === 0;
const isLast = index === strokes.length - 1;
const query = isLast ? stroke : strokes.slice(0, index + 1).join('');

if (isFirst) {
fireEvent.compositionStart(inputElement);
}

fireEvent.compositionUpdate(inputElement, {
data: query,
});

fireEvent.input(inputElement, {
isComposing: true,
target: {
value: query,
},
});

if (isLast) {
fireEvent.compositionEnd(inputElement, {
data: query,
target: {
value: query,
},
});
}
});

expect(inputElement).toHaveValue(character);
expect(getSources).toHaveBeenCalledTimes(1);
expect(getSources).toHaveBeenLastCalledWith(
expect.objectContaining({
query: character,
})
);
});
});

describe('onKeyDown', () => {
Expand Down Expand Up @@ -1913,6 +1974,85 @@ describe('getInputProps', () => {
);
});
});

test('stops process if IME composition is in progress`', () => {
const onStateChange = jest.fn();
const { inputElement } = createPlayground(createAutocomplete, {
openOnFocus: true,
onStateChange,
initialState: {
collections: [
createCollection({
source: { sourceId: 'testSource' },
items: [
{ label: '1' },
{ label: '2' },
{ label: '3' },
{ label: '4' },
],
}),
],
},
});

inputElement.focus();

// 1. Pressing Arrow Down to select the first item
fireEvent.keyDown(inputElement, { key: 'ArrowDown' });
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
activeItemId: 0,
}),
})
);

// 2. Typing かくてい with a Japanese IME
const strokes = ['か', 'く', 'て', 'い'];
strokes.forEach((_stroke, index) => {
const isFirst = index === 0;
const query = strokes.slice(0, index + 1).join('');

if (isFirst) {
fireEvent.compositionStart(inputElement);
}

fireEvent.compositionUpdate(inputElement, {
data: query,
});

fireEvent.input(inputElement, {
isComposing: true,
data: query,
target: {
value: query,
},
});
});

// 3. Checking that activeItemId has reverted to null due to input change
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
activeItemId: null,
}),
})
);

// 4. Selecting the 3rd suggestion on the IME window
fireEvent.keyDown(inputElement, { key: 'ArrowDown', isComposing: true });
fireEvent.keyDown(inputElement, { key: 'ArrowDown', isComposing: true });
fireEvent.keyDown(inputElement, { key: 'ArrowDown', isComposing: true });

// 5. Checking that activeItemId has not changed
expect(onStateChange).toHaveBeenLastCalledWith(
expect.objectContaining({
state: expect.objectContaining({
activeItemId: null,
}),
})
);
});
});

describe('onFocus', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/autocomplete-core/src/getDefaultProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function getDefaultProps<TItem extends BaseItem>(
debug: false,
openOnFocus: false,
enterKeyHint: undefined,
ignoreCompositionEvents: false,
placeholder: '',
autoFocus: false,
defaultActiveItemId: null,
Expand Down
27 changes: 27 additions & 0 deletions packages/autocomplete-core/src/getPropGetters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getAutocompleteElementId,
isOrContainsNode,
isSamsung,
getNativeEvent,
} from './utils';

interface GetPropGettersOptions<TItem extends BaseItem>
Expand Down Expand Up @@ -219,6 +220,28 @@ export function getPropGetters<
maxLength,
type: 'search',
onChange: (event) => {
const value = (
(event as unknown as Event).currentTarget as HTMLInputElement
).value;

if (
props.ignoreCompositionEvents &&
getNativeEvent(event as unknown as InputEvent).isComposing
) {
setters.setQuery(value);
return;
}

onInput({
event,
props,
query: value.slice(0, maxLength),
refresh,
store,
...setters,
});
},
onCompositionEnd: (event) => {
onInput({
event,
props,
Expand All @@ -231,6 +254,10 @@ export function getPropGetters<
});
},
onKeyDown: (event) => {
if (getNativeEvent(event as unknown as InputEvent).isComposing) {
return;
}

onKeyDown({
event: event as unknown as KeyboardEvent,
props,
Expand Down
3 changes: 3 additions & 0 deletions packages/autocomplete-core/src/utils/getNativeEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getNativeEvent<TEvent>(event: TEvent) {
return (event as unknown as { nativeEvent: TEvent }).nativeEvent || event;
}
1 change: 1 addition & 0 deletions packages/autocomplete-core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './getAutocompleteElementId';
export * from './isOrContainsNode';
export * from './isSamsung';
export * from './mapToAlgoliaResponse';
export * from './getNativeEvent';
3 changes: 3 additions & 0 deletions packages/autocomplete-js/src/utils/setProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ function getNormalizedName(name: string): string {
switch (name) {
case 'onChange':
return 'onInput';
// see: https://github.com/preactjs/preact/issues/1978
case 'onCompositionEnd':
return 'oncompositionend';
default:
return name;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/autocomplete-shared/src/core/AutocompleteOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ export interface AutocompleteOptions<TItem extends BaseItem> {
* @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-enterkeyhint
*/
enterKeyHint?: AutocompleteEnterKeyHint;
/**
* Whether to update the search input value in the middle of a
* composition session.
*
* @default false
* @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-ignorecompositionevents
*/
ignoreCompositionEvents?: boolean;
/**
* The placeholder text to show in the search input when there's no query.
*
Expand Down Expand Up @@ -200,6 +208,7 @@ export interface InternalAutocompleteOptions<TItem extends BaseItem>
id: string;
onStateChange(props: OnStateChangeProps<TItem>): void;
enterKeyHint: AutocompleteEnterKeyHint | undefined;
ignoreCompositionEvents: boolean;
placeholder: string;
autoFocus: boolean;
defaultActiveItemId: number | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export type GetInputProps<TEvent, TMouseEvent, TKeyboardEvent> = (props: {
'aria-controls': string | undefined;
'aria-labelledby': string;
onChange(event: TEvent): void;
onCompositionEnd(event: TEvent): void;
onKeyDown(event: TKeyboardEvent): void;
onFocus(event: TEvent): void;
onBlur(): void;
Expand Down
1 change: 1 addition & 0 deletions test/utils/createPlayground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function createPlayground<TItem extends Record<string, unknown>>(
const formProps = autocomplete.getFormProps({ inputElement });
inputElement.addEventListener('blur', inputProps.onBlur);
inputElement.addEventListener('input', inputProps.onChange);
inputElement.addEventListener('compositionend', inputProps.onCompositionEnd);
inputElement.addEventListener('click', inputProps.onClick);
inputElement.addEventListener('focus', inputProps.onFocus);
inputElement.addEventListener('keydown', inputProps.onKeyDown);
Expand Down

0 comments on commit fba16e5

Please sign in to comment.