Skip to content
Open
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
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
12 changes: 10 additions & 2 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 @@ -653,7 +657,11 @@ const Select = React.forwardRef<BaseSelectRef, SelectProps<any, DefaultOptionTyp
triggerChange(searchText);
}

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;
82 changes: 82 additions & 0 deletions tests/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,88 @@ 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('commits combobox composition text when compositionend is the first value event', () => {
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);
input.value = '中文';
fireEvent.compositionEnd(input);

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

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