Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Select also accepts public props from `BaseSelect`, except `showSearch`, which i
| onActive | Called when the active value changes. | `(value: ValueType) => void` | - |
| onChange | Called when selected value changes. | `(value: ValueType, option?: OptionType \| OptionType[]) => void` | - |
| onDeselect | Called when a value is deselected. | `(value, option) => void` | - |
| onSearch | Deprecated. Use `showSearch.onSearch` instead. | `(value: string) => void` | - |
| onSearch | Deprecated. Use `showSearch.onSearch` instead. Composition events include `info.isComposing`. | `(value: string, info?: { isComposing: boolean }) => void` | - |
| onSelect | Called when a value is selected. | `(value, option) => void` | - |
| optionFilterProp | Deprecated. Use `showSearch.optionFilterProp` instead. | string \| string[] | - |
| optionLabelProp | Option prop used as the selected label. | string | - |
Expand All @@ -114,7 +114,7 @@ Select also accepts public props from `BaseSelect`, except `showSearch`, which i
| autoClearSearchValue | Clear search input after selecting or deselecting in multiple mode. | boolean | true |
| filterOption | Filter options by search input. | boolean \| `FilterFunc<OptionType>` | - |
| filterSort | Sort filtered options. | `(optionA, optionB, info: { searchValue: string }) => number` | - |
| onSearch | Called when search input changes. | `(value: string) => void` | - |
| onSearch | Called when search input changes. Composition events include `info.isComposing`. | `(value: string, info?: { isComposing: boolean }) => void` | - |
| optionFilterProp | Option prop used for filtering when `filterOption` is enabled. | string \| string[] | - |
| searchValue | Controlled search input value. | string | - |

Expand Down
15 changes: 13 additions & 2 deletions src/BaseSelect/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export interface BaseSelectPrivateProps {
| 'effect' // Code logic trigger
| 'submit' // tag mode only
| 'blur'; // Not trigger event
/** Only provided for input method composition events. */
isComposing?: boolean;
/** Internal marker for the final composition event. */
isCompositionEnd?: boolean;
},
) => void;
/** Trigger when search text match the `tokenSeparators`. Will provide split content */
Expand Down Expand Up @@ -395,7 +399,12 @@ const BaseSelect = React.forwardRef<BaseSelectRef, BaseSelectProps>((props, ref)
return (input: string, end?: number) => getSeparatedContent(input, tokenSeparators, end);
}, [tokenSeparators]);

const onInternalSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => {
const onInternalSearch = (
searchText: string,
fromTyping: boolean,
isCompositing: boolean,
isCompositionEnd?: boolean,
) => {
if (multiple && isValidCount(maxCount) && displayValues.length >= maxCount) {
return;
}
Expand All @@ -419,9 +428,11 @@ const BaseSelect = React.forwardRef<BaseSelectRef, BaseSelectProps>((props, ref)
ret = false;
}

if (onSearch && mergedSearchValue !== newSearchText) {
if (onSearch && (mergedSearchValue !== newSearchText || isCompositionEnd)) {
onSearch(newSearchText, {
source: fromTyping ? 'typing' : 'effect',
isComposing: isCompositing || isCompositionEnd ? isCompositing : undefined,
isCompositionEnd,
});
}

Expand Down
14 changes: 11 additions & 3 deletions src/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,14 @@ type ArrayElementType<T> = T extends (infer E)[] ? E : T;

export type SemanticName = BaseSelectSemanticName;
export type PopupSemantic = 'listItem' | 'list';
export interface SearchInfo {
/** Whether the search text is part of an active input method composition. */
isComposing: boolean;
}
export interface SearchConfig<OptionType> {
searchValue?: string;
autoClearSearchValue?: boolean;
onSearch?: (value: string) => void;
onSearch?: (value: string, info?: SearchInfo) => void;
filterOption?: boolean | FilterFunc<OptionType>;
filterSort?: (optionA: OptionType, optionB: OptionType, info: { searchValue: string }) => number;
optionFilterProp?: string | string[];
Expand Down Expand Up @@ -649,11 +653,15 @@ const Select = React.forwardRef<BaseSelectRef, SelectProps<any, DefaultOptionTyp
}

if (info.source !== 'blur') {
if (mode === 'combobox') {
if (mode === 'combobox' && !info.isCompositionEnd) {
triggerChange(searchText);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

onSearch?.(searchText);
if (typeof info.isComposing === 'boolean') {
onSearch?.(searchText, { isComposing: info.isComposing });
} else {
onSearch?.(searchText);
}
}
};

Expand Down
6 changes: 2 additions & 4 deletions src/SelectInput/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
compositionStatusRef.current = false;

// Trigger search when input method composition ends, similar to original Selector
if (mode !== 'combobox') {
const { value: nextVal } = event.currentTarget;
onSearch?.(nextVal, true, false);
}
const { value: nextVal } = event.currentTarget;
onSearch?.(nextVal, true, false, true);
};

// Handle paste events to track pasted content
Expand Down
7 changes: 6 additions & 1 deletion src/SelectInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ export interface SelectInputProps extends Omit<React.HTMLAttributes<HTMLDivEleme
activeValue?: string;
mode?: Mode;
autoClearSearchValue?: boolean;
onSearch?: (searchText: string, fromTyping: boolean, isCompositing: boolean) => void;
onSearch?: (
searchText: string,
fromTyping: boolean,
isCompositing: boolean,
isCompositionEnd?: boolean,
) => void;
onSearchSubmit?: (searchText: string) => void;
onInputBlur?: () => void;
onClearMouseDown?: React.MouseEventHandler<HTMLElement>;
Expand Down
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import Select from './Select';
import Option from './Option';
import OptGroup from './OptGroup';
import type { BaseOptionType, DefaultOptionType, SearchConfig, SelectProps } from './Select';
import type {
BaseOptionType,
DefaultOptionType,
SearchConfig,
SearchInfo,
SelectProps,
} from './Select';
import BaseSelect from './BaseSelect';
import type { BaseSelectProps, BaseSelectRef, BaseSelectPropsWithoutPrivate } from './BaseSelect';
import useBaseProps from './hooks/useBaseProps';
import type { OptionProps } from './Option';

export { Option, OptGroup, BaseSelect, useBaseProps };
export type { BaseOptionType, DefaultOptionType, OptionProps, SearchConfig };
export type { BaseOptionType, DefaultOptionType, OptionProps, SearchConfig, SearchInfo };
export type { SelectProps, BaseSelectProps, BaseSelectRef, BaseSelectPropsWithoutPrivate };

export default Select;
64 changes: 64 additions & 0 deletions tests/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,70 @@ describe('Select.Basic', () => {
expect(handleSearch).toHaveBeenCalledWith('');
});

it('reports input composition state to search events', () => {
const handleSearch = jest.fn();
const Test = () => {
const [searchValue, setSearchValue] = React.useState('');

return (
<Select
showSearch={{
searchValue,
onSearch: (...args) => {
setSearchValue(args[0]);
handleSearch(...args);
},
}}
>
<Option value="中文">中文</Option>
</Select>
);
};
const { container } = render(<Test />);
const input = container.querySelector('input');

fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: 'ㄓ' } });
fireEvent.change(input, { target: { value: '中文' } });

expect(input).toHaveValue('中文');
expect(handleSearch).toHaveBeenNthCalledWith(1, 'ㄓ', { isComposing: true });
expect(handleSearch).toHaveBeenNthCalledWith(2, '中文', { isComposing: true });

fireEvent.compositionEnd(input);

expect(handleSearch).toHaveBeenCalledTimes(3);
expect(handleSearch).toHaveBeenNthCalledWith(3, '中文', { isComposing: false });

// Browsers may dispatch a final input event after compositionend. Do not duplicate it.
fireEvent.change(input, { target: { value: '中文' } });
expect(handleSearch).toHaveBeenCalledTimes(3);

fireEvent.change(input, { target: { value: '中文a' } });
expect(handleSearch).toHaveBeenNthCalledWith(4, '中文a');
});

it('reports completed combobox composition without duplicating change', () => {
const handleSearch = jest.fn();
const handleChange = jest.fn();
const { container } = render(
<Select mode="combobox" onSearch={handleSearch} onChange={handleChange} />,
);
const input = container.querySelector('input');

fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: '中文' } });

expect(handleSearch).toHaveBeenNthCalledWith(1, '中文', { isComposing: true });
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith('中文', {});

fireEvent.compositionEnd(input);

expect(handleSearch).toHaveBeenNthCalledWith(2, '中文', { isComposing: false });
expect(handleChange).toHaveBeenCalledTimes(1);
});

it('not fires search event when user select', () => {
const handleSearch = jest.fn();
const { container } = render(
Expand Down