Skip to content

Commit

Permalink
fix(searchbox): only refine if composition ended when using an IME (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
dhayab authored Dec 14, 2023
1 parent b62f2bc commit 0820455
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 24 deletions.
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",
"maxSize": "166.75 kB"
"maxSize": "167 kB"
},
{
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
Expand Down
18 changes: 13 additions & 5 deletions packages/instantsearch.js/src/components/SearchBox/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ class SearchBox extends Component<
const { searchAsYouType, refine, onChange } = this.props;
const query = (event.target as HTMLInputElement).value;

if (searchAsYouType) {
refine(query);
if (
event.type === 'compositionend' ||
!(event as KeyboardEvent).isComposing
) {
if (searchAsYouType) {
refine(query);
}
this.setState({ query });

onChange(event);
}
this.setState({ query });

onChange(event);
};

public componentWillReceiveProps(nextProps: SearchBoxPropsWithDefaultProps) {
Expand Down Expand Up @@ -184,6 +189,9 @@ class SearchBox extends Component<
spellCheck="false"
maxLength={512}
onInput={this.onInput}
// see: https://github.com/preactjs/preact/issues/1978
// eslint-disable-next-line react/no-unknown-property
oncompositionend={this.onInput}
onBlur={this.onBlur}
onFocus={this.onFocus}
aria-label={ariaLabel}
Expand Down
11 changes: 7 additions & 4 deletions packages/react-instantsearch/src/ui/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ export type SearchBoxProps = Omit<
> &
Pick<React.ComponentProps<'form'>, 'onSubmit'> &
Required<Pick<React.ComponentProps<'form'>, 'onReset'>> &
Pick<
React.ComponentProps<'input'>,
'placeholder' | 'onChange' | 'autoFocus'
> & {
Pick<React.ComponentProps<'input'>, 'placeholder' | 'autoFocus'> & {
onChange?: (
event:
| React.ChangeEvent<HTMLInputElement>
| React.CompositionEvent<HTMLInputElement>
) => void;
formRef?: React.RefObject<HTMLFormElement>;
inputRef: React.RefObject<HTMLInputElement>;
isSearchStalled: boolean;
Expand Down Expand Up @@ -203,6 +205,7 @@ export function SearchBox({
type="search"
value={value}
onChange={onChange}
onCompositionEnd={onChange}
autoFocus={autoFocus}
/>
<button
Expand Down
18 changes: 13 additions & 5 deletions packages/react-instantsearch/src/widgets/RefinementList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { RefinementList as RefinementListUiComponent } from '../ui/RefinementLis
import { SearchBox as SearchBoxUiComponent } from '../ui/SearchBox';

import type { RefinementListProps as RefinementListUiComponentProps } from '../ui/RefinementList';
import type { SearchBoxTranslations } from '../ui/SearchBox';
import type { SearchBoxProps, SearchBoxTranslations } from '../ui/SearchBox';
import type { RefinementListItem } from 'instantsearch.js/es/connectors/refinement-list/connectRefinementList';
import type { RefinementListWidgetParams } from 'instantsearch.js/es/widgets/refinement-list/refinement-list';
import type { UseRefinementListProps } from 'react-instantsearch-core';
Expand Down Expand Up @@ -82,18 +82,26 @@ export function RefinementList({
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);

function setQuery(newQuery: string) {
function setQuery(newQuery: string, compositionComplete = true) {
setInputValue(newQuery);
searchForItems(newQuery);
if (compositionComplete) {
searchForItems(newQuery);
}
}

function onRefine(item: RefinementListItem) {
refine(item.value);
setQuery('');
}

function onChange(event: React.ChangeEvent<HTMLInputElement>) {
setQuery(event.currentTarget.value);
function onChange(
event: Parameters<NonNullable<SearchBoxProps['onChange']>>[0]
) {
const compositionComplete =
event.type === 'compositionend' ||
!(event.nativeEvent as KeyboardEvent).isComposing;

setQuery(event.currentTarget.value, compositionComplete);
}

function onReset() {
Expand Down
14 changes: 10 additions & 4 deletions packages/react-instantsearch/src/widgets/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ export function SearchBox({
const [inputValue, setInputValue] = useState(query);
const inputRef = useRef<HTMLInputElement>(null);

function setQuery(newQuery: string) {
function setQuery(newQuery: string, compositionComplete = true) {
setInputValue(newQuery);

if (searchAsYouType) {
if (searchAsYouType && compositionComplete) {
refine(newQuery);
}
}
Expand All @@ -60,8 +60,14 @@ export function SearchBox({
}
}

function onChange(event: React.ChangeEvent<HTMLInputElement>) {
setQuery(event.currentTarget.value);
function onChange(
event: Parameters<NonNullable<SearchBoxUiComponentProps['onChange']>>[0]
) {
const compositionComplete =
event.type === 'compositionend' ||
!(event.nativeEvent as KeyboardEvent).isComposing;

setQuery(event.currentTarget.value, compositionComplete);
}

function onSubmit(event: React.FormEvent<HTMLFormElement>) {
Expand Down
12 changes: 8 additions & 4 deletions packages/vue-instantsearch/src/components/SearchInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@
:value="value || modelValue"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
@input="
$emit('input', $event.target.value);
$emit('update:modelValue', $event.target.value);
"
@input="onInput($event)"
@compositionend="onInput($event)"
ref="input"
/>
<button
Expand Down Expand Up @@ -162,6 +160,12 @@ export default {
isFocused() {
return document.activeElement === this.$refs.input;
},
onInput(event) {
if (event.type === 'compositionend' || !event.isComposing) {
this.$emit('input', event.target.value);
this.$emit('update:modelValue', event.target.value);
}
},
onFormSubmit() {
const input = this.$refs.input;
input.blur();
Expand Down
70 changes: 69 additions & 1 deletion tests/common/widgets/search-box/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import {
createSearchClient,
} from '@instantsearch/mocks';
import {
castToJestMock,
normalizeSnapshot as commonNormalizeSnapshot,
wait,
} from '@instantsearch/testutils';
import { screen } from '@testing-library/dom';
import { fireEvent, screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';

import { skippableDescribe } from '../../common';
Expand Down Expand Up @@ -307,6 +308,73 @@ export function createOptionsTests(
expect(screen.getByRole('searchbox')).toHaveValue('iPhone');
});

test('refines query only when composition is complete', async () => {
const searchClient = createSearchClient({});

await setup({
instantSearchOptions: {
indexName: 'indexName',
searchClient,
},
widgetParams: {},
});

// 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];

await act(async () => {
await wait(0);
castToJestMock(searchClient.search).mockClear();

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(screen.getByRole('searchbox'));
}

fireEvent.compositionUpdate(screen.getByRole('searchbox'), {
data: query,
});

fireEvent.input(screen.getByRole('searchbox'), {
isComposing: true,
target: {
value: query,
},
});

if (isLast) {
fireEvent.compositionEnd(screen.getByRole('searchbox'), {
data: query,
target: {
value: query,
},
});
}
});
});

expect(screen.getByRole('searchbox')).toHaveValue(character);

expect(searchClient.search).toHaveBeenCalledTimes(1);
expect(searchClient.search).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
params: expect.objectContaining({
query: character,
}),
}),
])
);
});

test('resets query when clicking on reset button', async () => {
let state: UiState = {};
const searchClient = createSearchClient({});
Expand Down

0 comments on commit 0820455

Please sign in to comment.