Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 131 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,66 @@ describe('getInputProps', () => {

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

test('stops process if IME composition is in progress', () => {
const getSources = jest.fn((..._args: any[]) => {
return [
createSource({
getItems() {
return [{ label: '1' }, { label: '2' }];
},
}),
];
});
const { inputElement } = createPlayground(createAutocomplete, {
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 +1973,76 @@ describe('getInputProps', () => {
);
});
});

test('stops process if IME 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. 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 });

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

describe('onFocus', () => {
Expand Down
24 changes: 24 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,25 @@ export function getPropGetters<
maxLength,
type: 'search',
onChange: (event) => {
const value = (
(event as unknown as Event).currentTarget as HTMLInputElement
).value;

if (getNativeEvent(event as unknown as InputEvent).isComposing) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the compatibility layer in core makes sense here, but I wonder if it actually should be part of the react layer? I guess once that's not just an example anymore?)

Copy link
Copy Markdown
Member

@aymeric-giraudet aymeric-giraudet Jan 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean as user land code as there's no React layer ?
I think it's fine to just have a condition checking whether there's nativeEvent or not, doesn't add much overhead

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I was thinking as user land code, but it's indeed easy to forget

setters.setQuery(value);
return;
}

onInput({
event,
props,
query: value.slice(0, maxLength),
refresh,
store,
...setters,
});
},
onCompositionEnd: (event) => {
onInput({
event,
props,
Expand All @@ -231,6 +251,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
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