Skip to content
Draft
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
3 changes: 3 additions & 0 deletions packages/components/input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@ const Input = forwardRefWithStatics(
const {
currentTarget: { value },
} = e;
// 合成期间输入框展示的是 composingValue,而外部 value 可能已被程序清空(如 Select 选中选项后清空筛选词),
// 此时需要以输入框当前内容为起点重新累计,否则上一次的合成内容会被残留并累加
setComposingValue(value);
onCompositionstart?.(value, { e });
}
function handleCompositionEnd(e: React.CompositionEvent<HTMLInputElement>) {
Expand Down
43 changes: 43 additions & 0 deletions packages/components/input/__tests__/input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,49 @@ describe('Input 组件测试', () => {
expect(onCompositionEndFn).toHaveBeenCalled();
expect(InputDom.value).toBe([InputValue, InputValue, InputValue].join(''));
});
test('composing value should not be kept when value is reset by outside', async () => {
// 模拟中文输入法的一次合成输入
const imeInput = (input: HTMLInputElement, value: string) => {
fireEvent.compositionStart(input, { target: { value: input.value } });
fireEvent.change(input, { target: { value } });
fireEvent.compositionEnd(input, { target: { value } });
};

const ControlledInput = () => {
const [value, setValue] = React.useState('');
const [, setCompositionCount] = React.useState(0);
return (
<>
<Input
placeholder={InputPlaceholder}
value={value}
onChange={(v) => setValue(v as string)}
// 开始合成时触发重渲染,复现 SelectInput 的 setIsTyping 更新。
onCompositionstart={() => setCompositionCount((count) => count + 1)}
/>
<button type="button" onClick={() => setValue('')}>
reset
</button>
</>
);
};
const { queryByPlaceholderText, getByText } = render(<ControlledInput />);
const InputDom = queryByPlaceholderText(InputPlaceholder) as HTMLInputElement;

imeInput(InputDom, '苹');
expect(InputDom.value).toBe('苹');

// 外部清空输入框内容后,再次进入合成态不应残留上一次的输入
fireEvent.click(getByText('reset'));
expect(InputDom.value).toBe('');

fireEvent.compositionStart(InputDom, { target: { value: InputDom.value } });
expect(InputDom.value).toBe('');

fireEvent.change(InputDom, { target: { value: 'xiang' } });
fireEvent.compositionEnd(InputDom, { target: { value: '香' } });
expect(InputDom.value).toBe('香');
});
test('keyDown', async () => {
const user = userEvent.setup();
const onEnterFn = vi.fn();
Expand Down
144 changes: 144 additions & 0 deletions packages/components/select/__tests__/closing-animation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { act, fireEvent, render, vi } from '@test/utils';

import Select from '../index';

test.each([
{ source: 'options', destroyOnClose: false },
{ source: 'children', destroyOnClose: false },
{ source: 'options', destroyOnClose: true },
{ source: 'children', destroyOnClose: true },
])('keeps the filtered list during exit: $source, destroyOnClose=$destroyOnClose', ({ source, destroyOnClose }) => {
vi.useFakeTimers();
const onChange = vi.fn();
const onInputChange = vi.fn();
const Demo = () => {
const [value, setValue] = useState('');
const options = [
{ label: '选项一', value: '1' },
{ label: '选项二', value: '2' },
{ label: '选项三', value: '3' },
];
return (
<Select
value={value}
onChange={(next) => {
onChange(next);
setValue(String(next));
}}
onInputChange={onInputChange}
filterable
popupProps={{ destroyOnClose }}
options={source === 'options' ? options : undefined}
>
{source === 'children' ? options.map((option) => <Select.Option key={option.value} {...option} />) : undefined}
</Select>
);
};
const { getByRole, getByText, unmount } = render(<Demo />);
try {
const input = getByRole('textbox');
fireEvent.click(input);
act(() => vi.advanceTimersByTime(200));
fireEvent.change(input, { target: { value: '一' } });
const popup = document.querySelector('.t-popup');
expect(popup).toHaveTextContent('选项一');
expect(popup).not.toHaveTextContent('选项二');

fireEvent.click(getByText('选项一'));
expect(input).toHaveValue('选项一');
expect(onChange).toHaveBeenLastCalledWith('1');
expect(onInputChange).toHaveBeenLastCalledWith('', expect.objectContaining({ trigger: 'blur' }));
expect(popup).toHaveStyle({ display: 'block' });
expect(popup).not.toHaveTextContent('选项二');
expect(popup).not.toHaveTextContent('选项三');

act(() => vi.advanceTimersByTime(100));
expect(popup).toHaveStyle({ display: 'block' });
expect(popup).not.toHaveTextContent('选项二');
act(() => vi.advanceTimersByTime(100));
if (destroyOnClose) expect(document.querySelector('.t-popup')).toBeNull();
else expect(popup).toHaveStyle({ display: 'none' });

fireEvent.click(input);
const reopenedPopup = document.querySelector('.t-popup');
expect(reopenedPopup).toHaveTextContent('选项一');
expect(reopenedPopup).toHaveTextContent('选项二');
expect(reopenedPopup).toHaveTextContent('选项三');
expect(onChange).toHaveBeenCalledTimes(1);
} finally {
unmount();
vi.useRealTimers();
}
});

const options = [
{ label: '选项一', value: '1' },
{ label: '选项二', value: '2' },
{ label: '选项三', value: '3' },
];

test('reopening during exit uses the latest options and cancels the pending close', () => {
vi.useFakeTimers();
const onChange = vi.fn();
const { getByRole, getByText, rerender, unmount } = render(
<Select filterable options={options} onChange={onChange} />,
);
try {
const input = getByRole('textbox');
fireEvent.click(input);
act(() => vi.advanceTimersByTime(200));
fireEvent.change(input, { target: { value: '一' } });
fireEvent.click(getByText('选项一'));
const popup = document.querySelector('.t-popup');
expect(popup).not.toHaveTextContent('选项二');

rerender(<Select filterable options={[...options, { label: '选项四', value: '4' }]} onChange={onChange} />);
expect(popup).not.toHaveTextContent('选项四');
fireEvent.click(input);
expect(input).toHaveValue('');
expect(popup).toHaveTextContent('选项二');
expect(popup).toHaveTextContent('选项四');
act(() => vi.advanceTimersByTime(200));
expect(popup).toHaveStyle({ display: 'block' });
expect(onChange).toHaveBeenCalledTimes(1);
} finally {
unmount();
vi.useRealTimers();
}
});

test.each([false, true])('multiple selection keeps the open list live: reserveKeyword=%s', (reserveKeyword) => {
const { getByRole, getByText } = render(
<Select multiple filterable options={options} reserveKeyword={reserveKeyword} />,
);
const input = getByRole('textbox');
fireEvent.click(input);
fireEvent.change(input, { target: { value: '一' } });
fireEvent.click(getByText('选项一'));
const popup = document.querySelector('.t-popup');
expect(popup).toHaveStyle({ display: 'block' });
if (reserveKeyword) {
expect(input).toHaveValue('一');
expect(popup).not.toHaveTextContent('选项二');
} else {
expect(input).toHaveValue('');
expect(popup).toHaveTextContent('选项二');
}
});

test('a controlled popup that stays open continues updating its list', () => {
const onPopupVisibleChange = vi.fn();
const { getByRole, getByText } = render(
<Select filterable options={options} popupVisible onPopupVisibleChange={onPopupVisibleChange} />,
);
const input = getByRole('textbox');
fireEvent.change(input, { target: { value: '一' } });
fireEvent.click(getByText('选项一'));
expect(onPopupVisibleChange).toHaveBeenLastCalledWith(false, expect.anything());
expect(document.querySelector('.t-popup')).toHaveStyle({ display: 'block' });
expect(document.querySelector('.t-popup')).toHaveTextContent('选项二');
fireEvent.change(input, { target: { value: '三' } });
expect(document.querySelector('.t-popup')).toHaveTextContent('选项三');
expect(document.querySelector('.t-popup')).not.toHaveTextContent('选项二');
});
Loading
Loading