From af25de000fd029ee4005be3ffac01dba7ae55e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Wed, 2 Sep 2026 20:22:15 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(Select):=20=E4=BF=AE=E5=A4=8D=20filtera?= =?UTF-8?q?ble=20=E4=B8=8B=E4=B8=AD=E6=96=87=E8=BE=93=E5=85=A5=E6=B3=95?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E9=80=89=E4=B8=AD=E5=90=8E=E5=86=8D=E6=AC=A1?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E4=BC=9A=E6=AE=8B=E7=95=99=E4=B8=8A=E6=AC=A1?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=86=85=E5=AE=B9=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input 在合成输入(composition)期间使用内部状态 composingValue 渲染,外部 value 被程序清空时该状态不会同步。Select 选中选项后会清空筛选词,但 composingValue 仍保留上一次的合成内容,导致再次聚焦进入合成态时,输入框回显旧内容并在其后累加新输入。 - fix(Input): 合成输入开始时以输入框当前内容为起点重置 composingValue - test: 补充 Input 受控清空后再次合成输入、Select filterable 中文输入法筛选的回归测试 closes #4382 --- packages/components/input/Input.tsx | 3 ++ .../components/input/__tests__/input.test.tsx | 36 +++++++++++++ .../select/__tests__/select.test.tsx | 51 +++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/packages/components/input/Input.tsx b/packages/components/input/Input.tsx index f9ed726335..b3fd4238f1 100644 --- a/packages/components/input/Input.tsx +++ b/packages/components/input/Input.tsx @@ -348,6 +348,9 @@ const Input = forwardRefWithStatics( const { currentTarget: { value }, } = e; + // 合成期间输入框展示的是 composingValue,而外部 value 可能已被程序清空(如 Select 选中选项后清空筛选词), + // 此时需要以输入框当前内容为起点重新累计,否则上一次的合成内容会被残留并累加 + setComposingValue(value); onCompositionstart?.(value, { e }); } function handleCompositionEnd(e: React.CompositionEvent) { diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index acf778a463..e8c36fe967 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -79,6 +79,42 @@ 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(''); + return ( + <> + setValue(v as string)} /> + + + ); + }; + const { queryByPlaceholderText, getByText } = render(); + 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(); diff --git a/packages/components/select/__tests__/select.test.tsx b/packages/components/select/__tests__/select.test.tsx index 32dc07e70e..8022b9dfdd 100644 --- a/packages/components/select/__tests__/select.test.tsx +++ b/packages/components/select/__tests__/select.test.tsx @@ -359,6 +359,57 @@ describe('Select 组件测试', () => { expect(document.querySelector(popupSelector)).toHaveTextContent('无数据'); }); + test('可过滤选择器中文输入法测试', async () => { + const testId = 'test-id-ime'; + // 模拟中文输入法的一次合成输入 + const imeInput = (input: HTMLInputElement, value: string) => { + fireEvent.compositionStart(input, { target: { value: input.value } }); + fireEvent.change(input, { target: { value } }); + fireEvent.compositionEnd(input, { target: { value } }); + }; + const cnOptions = [ + { label: '苹果', value: 'apple' }, + { label: '香蕉', value: 'banana' }, + { label: '橙子', value: 'orange' }, + ]; + + const FilterableSelect = () => { + const [value, setValue] = useState(); + const onChange = (value) => { + setValue(value); + }; + + return ( + + ); + }; + const { getByPlaceholderText, getByText } = render(); + const input = getByPlaceholderText(testId) as HTMLInputElement; + + // 中文输入法输入“苹”,筛选出“苹果” + fireEvent.click(input); + imeInput(input, '苹'); + expect(input).toHaveValue('苹'); + expect(document.querySelector(popupSelector)).toHaveTextContent('苹果'); + + // 选中“苹果”后,输入框展示选中项 + fireEvent.click(getByText('苹果')); + expect(input).toHaveValue('苹果'); + + // 再次聚焦并输入,不应保留上一次的筛选内容 + fireEvent.click(input); + expect(input).toHaveValue(''); + fireEvent.compositionStart(input, { target: { value: input.value } }); + expect(input).toHaveValue(''); + fireEvent.change(input, { target: { value: 'xiang' } }); + fireEvent.compositionEnd(input, { target: { value: '香' } }); + expect(input).toHaveValue('香'); + }); + test('远程搜索测试', async () => { const user = userEvent.setup(); render(); From 6ab8b7187b0a0802289bd91da863103ebeb6ff22 Mon Sep 17 00:00:00 2001 From: tdesign-bot Date: Thu, 3 Sep 2026 10:59:44 +0000 Subject: [PATCH 2/4] chore: stash changelog [ci skip] --- packages/tdesign-react/.changelog/pr-4388.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/tdesign-react/.changelog/pr-4388.md diff --git a/packages/tdesign-react/.changelog/pr-4388.md b/packages/tdesign-react/.changelog/pr-4388.md new file mode 100644 index 0000000000..0837a41ba5 --- /dev/null +++ b/packages/tdesign-react/.changelog/pr-4388.md @@ -0,0 +1,6 @@ +--- +pr_number: 4388 +contributor: RSS1102 +--- + +- fix(Select): 修复 `filterable` 下中文输入法筛选选中后再次输入会残留上次筛选内容的问题 @RSS1102 ([#4388](https://github.com/Tencent/tdesign-react/pull/4388)) From d5fece76148c584d4c645bd7acab9865a704d93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Thu, 3 Sep 2026 19:26:02 +0800 Subject: [PATCH 3/4] fix(Select): preserve filtered options during popup exit --- .../components/input/__tests__/input.test.tsx | 9 +- .../__tests__/closing-animation.test.tsx | 144 ++++++++++++++++++ .../select/__tests__/select.test.tsx | 22 ++- packages/components/select/base/Select.tsx | 9 +- 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 packages/components/select/__tests__/closing-animation.test.tsx diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index e8c36fe967..2339b4ee77 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -89,9 +89,16 @@ describe('Input 组件测试', () => { const ControlledInput = () => { const [value, setValue] = React.useState(''); + const [, setCompositionCount] = React.useState(0); return ( <> - setValue(v as string)} /> + setValue(v as string)} + // 开始合成时触发重渲染,复现 SelectInput 的 setIsTyping 更新。 + onCompositionstart={() => setCompositionCount((count) => count + 1)} + /> diff --git a/packages/components/select/__tests__/closing-animation.test.tsx b/packages/components/select/__tests__/closing-animation.test.tsx new file mode 100644 index 0000000000..231152341b --- /dev/null +++ b/packages/components/select/__tests__/closing-animation.test.tsx @@ -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 ( + + ); + }; + const { getByRole, getByText, unmount } = render(); + 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( + ); + 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( + , + ); + 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('选项二'); +}); diff --git a/packages/components/select/__tests__/select.test.tsx b/packages/components/select/__tests__/select.test.tsx index 8022b9dfdd..749f4ef8e8 100644 --- a/packages/components/select/__tests__/select.test.tsx +++ b/packages/components/select/__tests__/select.test.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { act, fireEvent, mockTimeout, render } from '@test/utils'; +import { act, fireEvent, mockTimeout, render, vi } from '@test/utils'; import userEvent from '@testing-library/user-event'; import Popup from '../../popup'; @@ -361,6 +361,7 @@ describe('Select 组件测试', () => { test('可过滤选择器中文输入法测试', async () => { const testId = 'test-id-ime'; + const onSelectedChange = vi.fn(); // 模拟中文输入法的一次合成输入 const imeInput = (input: HTMLInputElement, value: string) => { fireEvent.compositionStart(input, { target: { value: input.value } }); @@ -376,6 +377,7 @@ describe('Select 组件测试', () => { const FilterableSelect = () => { const [value, setValue] = useState(); const onChange = (value) => { + onSelectedChange(value); setValue(value); }; @@ -395,10 +397,15 @@ describe('Select 组件测试', () => { imeInput(input, '苹'); expect(input).toHaveValue('苹'); expect(document.querySelector(popupSelector)).toHaveTextContent('苹果'); + expect(document.querySelector(popupSelector)).not.toHaveTextContent('香蕉'); + expect(document.querySelector(popupSelector)).not.toHaveTextContent('橙子'); + expect(onSelectedChange).not.toHaveBeenCalled(); // 选中“苹果”后,输入框展示选中项 fireEvent.click(getByText('苹果')); expect(input).toHaveValue('苹果'); + expect(onSelectedChange).toHaveBeenCalledTimes(1); + expect(onSelectedChange).toHaveBeenLastCalledWith('apple'); // 再次聚焦并输入,不应保留上一次的筛选内容 fireEvent.click(input); @@ -406,8 +413,21 @@ describe('Select 组件测试', () => { fireEvent.compositionStart(input, { target: { value: input.value } }); expect(input).toHaveValue(''); fireEvent.change(input, { target: { value: 'xiang' } }); + expect(input).toHaveValue('xiang'); + // 合成期间不提交筛选词,面板仍展示上一次已提交筛选词(此时为空)对应的结果。 + expect(document.querySelector(popupSelector)).toHaveTextContent('苹果'); + expect(document.querySelector(popupSelector)).toHaveTextContent('香蕉'); + expect(document.querySelector(popupSelector)).toHaveTextContent('橙子'); fireEvent.compositionEnd(input, { target: { value: '香' } }); expect(input).toHaveValue('香'); + expect(document.querySelector(popupSelector)).toHaveTextContent('香蕉'); + expect(document.querySelector(popupSelector)).not.toHaveTextContent('苹果'); + expect(document.querySelector(popupSelector)).not.toHaveTextContent('橙子'); + // 筛选不会清除或替换已选值;收起时仍显示原来的选中项。 + expect(onSelectedChange).toHaveBeenCalledTimes(1); + fireEvent.mouseDown(document.body); + expect(input).toHaveValue('苹果'); + expect(onSelectedChange).toHaveBeenCalledTimes(1); }); test('远程搜索测试', async () => { diff --git a/packages/components/select/base/Select.tsx b/packages/components/select/base/Select.tsx index 54b1bf8bb6..ecc7cf6fff 100644 --- a/packages/components/select/base/Select.tsx +++ b/packages/components/select/base/Select.tsx @@ -20,6 +20,7 @@ import FakeArrow from '../../common/FakeArrow'; import useConfig from '../../hooks/useConfig'; import useControlled from '../../hooks/useControlled'; import useDefaultProps from '../../hooks/useDefaultProps'; +import useLayoutEffect from '../../hooks/useLayoutEffect'; import Loading from '../../loading'; import { useLocaleReceiver } from '../../locale/LocalReceiver'; import SelectInput from '../../select-input'; @@ -124,6 +125,12 @@ const Select = forwardRefWithStatics( const { currentOptions, setCurrentOptions, tmpPropOptions, valueToOption, selectedOptions, flattenedOptions } = useOptions(keys, options, children, valueType, value, reserveKeyword); + // 清空筛选词时浮层仍在执行退出动画,保留关闭前的列表,避免提前展示全部选项。 + const lastVisibleOptionsRef = useRef(currentOptions); + useLayoutEffect(() => { + if (innerPopupVisible) lastVisibleOptionsRef.current = currentOptions; + }, [innerPopupVisible, currentOptions]); + const onCheckAllChange = useCallback( (checkAll: boolean, e: React.MouseEvent | React.KeyboardEvent) => { const isDisabledCheckAll = (opt: TdOptionProps) => opt.checkAll && opt.disabled; @@ -419,7 +426,7 @@ const Select = forwardRefWithStatics( showPopup: innerPopupVisible, // popup弹出层内容只会在点击事件之后触发 并且无任何透传参数 setShowPopup: (show: boolean) => handlePopupVisibleChange(show, {}), - options: currentOptions, + options: innerPopupVisible ? currentOptions : lastVisibleOptionsRef.current, empty, max, loadingText, From 99d6dc87c69767fc34b4736be5dbeec26760d183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Thu, 3 Sep 2026 20:06:31 +0800 Subject: [PATCH 4/4] fix(Select): avoid filtered option flicker --- .../select/__tests__/filtering.test.tsx | 231 ++++++++++++++++++ packages/components/select/base/Select.tsx | 121 +++++---- .../components/select/hooks/useOptions.ts | 52 ++-- packages/tdesign-react/CHANGELOG.en-US.md | 1 + packages/tdesign-react/CHANGELOG.md | 1 + 5 files changed, 312 insertions(+), 94 deletions(-) create mode 100644 packages/components/select/__tests__/filtering.test.tsx diff --git a/packages/components/select/__tests__/filtering.test.tsx b/packages/components/select/__tests__/filtering.test.tsx new file mode 100644 index 0000000000..09434a3bf3 --- /dev/null +++ b/packages/components/select/__tests__/filtering.test.tsx @@ -0,0 +1,231 @@ +import React, { Profiler, useState } from 'react'; +import { fireEvent, render, vi } from '@test/utils'; + +import Select from '../index'; + +// 记录真实 DOM commit,而不是仅检查 act 刷新全部 effects 后的最终结果。 +test.each(['options', 'children'] as const)('keeps an unchanged filter across parent rerenders: %s', (source) => { + const committedLists: string[] = []; + const readOptions = () => + Array.from(document.querySelectorAll('.t-popup .t-select-option')) + .map((option) => option.textContent) + .join('|'); + const Demo = () => { + const [, setRevision] = useState(0); + // 与官网 Demo 一样,在父组件 render 中创建 options / Option children。 + const options = [ + { label: '选项一', value: '1' }, + { label: '选项二', value: '2' }, + { label: '选项三', value: '3' }, + ]; + return ( + <> + + + + ); + }; + const { getByRole } = render( + { + const list = readOptions(); + if (committedLists[committedLists.length - 1] !== list) committedLists.push(list); + }} + > + + , + ); + expect(readOptions()).toBe('选项二'); + committedLists.length = 0; + fireEvent.click(getByRole('button', { name: 'rerender' })); + expect(getByRole('textbox')).toHaveValue('二'); + expect(readOptions()).toBe('选项二'); + expect(committedLists).toEqual(['选项二']); +}); + +const options = [ + { label: '选项一', value: '1' }, + { label: '选项二', value: '2' }, + { label: '选项三', value: '3' }, +]; + +const readOptions = () => + Array.from(document.querySelectorAll('.t-popup .t-select-option')).map((option) => option.textContent); + +test('commits only the new filtered list when the controlled query and options change together', () => { + const committedLists: string[][] = []; + const demo = (inputValue: string, items: typeof options) => ( + committedLists.push(readOptions())}> + + {source === 'children' ? items.map((option) => ) : undefined} + + ); + const { getByRole, getByText, rerender, container } = render(demo(options)); + const input = getByRole('textbox'); + fireEvent.click(input); + fireEvent.change(input, { target: { value: '一' } }); + fireEvent.click(getByText('选项一')); + rerender(demo([...options, { label: '选项十一', value: '11' }])); + expect(input).toHaveValue('一'); + expect(readOptions()).toEqual(['选项一', '选项十一']); + expect(container.querySelector('.t-tag')).toHaveTextContent('选项一'); + }, +); + +test('remote search uses server options and retains labels of selections absent from the response', () => { + const onSearch = vi.fn(); + const filter = vi.fn(() => false); + const demo = (items: typeof options) => ( + option.value === '1'} + />, + ); + expect(readOptions()).toEqual(['选项一']); + rerender( + ); + expect(readOptions()).toEqual([]); + rerender(, + ); + expect(readOptions()).toEqual(['四']); +}); + +test('filters grouped options while resolving selected labels from the full source', () => { + const { container } = render( + , + ); + expect(readOptions()).toEqual(['选项二']); + expect(container.querySelector('.t-tag')).toHaveTextContent('选项一'); +}); + +test('keyboard selection uses the current filtered group after an options update', () => { + const onChange = vi.fn(); + const { getByRole, rerender } = render( + , + ); + const popup = document.querySelector('.t-popup__content') as HTMLElement; + popup.scrollTo = vi.fn(); + fireEvent.keyDown(getByRole('textbox'), { key: 'ArrowDown', code: 'ArrowDown' }); + fireEvent.keyDown(getByRole('textbox'), { key: 'Enter', code: 'Enter' }); + expect(onChange).toHaveBeenLastCalledWith('4', expect.objectContaining({ trigger: 'check' })); +}); + +test('filters a virtual list down to a non-virtual result and back', () => { + const items = Array.from({ length: 120 }, (_, index) => ({ label: `选项${index}`, value: String(index) })); + const { getByRole } = render( +
自定义面板内容
+ , + ); + expect(document.querySelector('.t-popup')).toHaveTextContent('自定义面板内容'); +}); diff --git a/packages/components/select/base/Select.tsx b/packages/components/select/base/Select.tsx index ecc7cf6fff..672c96ec05 100644 --- a/packages/components/select/base/Select.tsx +++ b/packages/components/select/base/Select.tsx @@ -1,13 +1,4 @@ -import React, { - Children, - cloneElement, - isValidElement, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { Children, cloneElement, isValidElement, useCallback, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { debounce, get, isFunction } from 'lodash-es'; @@ -27,7 +18,7 @@ import SelectInput from '../../select-input'; import Tag from '../../tag'; import { selectDefaultProps } from '../defaultProps'; import useKeyboardControl from '../hooks/useKeyboardControl'; -import useOptions, { isSelectOptionGroup } from '../hooks/useOptions'; +import useOptions, { flattenOptions, isSelectOptionGroup } from '../hooks/useOptions'; import { getKeyMapping, getSelectedOptions, getSelectValueArr } from '../util/helper'; import Option from './Option'; import OptionGroup from './OptionGroup'; @@ -122,10 +113,60 @@ const Select = forwardRefWithStatics( setInnerPopupVisible(visible, ctx); }; - const { currentOptions, setCurrentOptions, tmpPropOptions, valueToOption, selectedOptions, flattenedOptions } = - useOptions(keys, options, children, valueType, value, reserveKeyword); + const { normalizedOptions, valueToOption, selectedOptions } = useOptions(keys, options, children, valueType, value); - // 清空筛选词时浮层仍在执行退出动画,保留关闭前的列表,避免提前展示全部选项。 + /** + * 同步计算过滤结果,避免先展示完整列表、再由 effect 过滤造成闪动。 + * 自定义 filter 在渲染阶段执行,应保持无副作用。 + */ + const currentOptions = useMemo(() => { + const value = inputValue === undefined ? '' : String(inputValue); + let filteredOptions: SelectOption[] = []; + if ((filterable && isFunction(onSearch)) || !value) { + return normalizedOptions; + } + + const filterLabels = []; + const filterMethods = (option: SelectOption) => { + if (filter && isFunction(filter)) { + return filter(value, option); + } + const upperValue = value.toUpperCase(); + const searchableText = extractTextFromTNode(option.label); + return searchableText.toUpperCase().includes(upperValue); + }; + + normalizedOptions?.forEach((option) => { + if (isSelectOptionGroup(option)) { + filteredOptions.push({ + ...option, + children: option.children?.filter((child) => { + if (filterMethods(child)) { + filterLabels.push(child.label); + return true; + } + return false; + }), + }); + } else if (filterMethods(option)) { + filterLabels.push(option.label); + filteredOptions.push(option); + } + }); + const isSameLabelOptionExist = filterLabels.includes(value); + if (creatable && !isSameLabelOptionExist) { + filteredOptions = filteredOptions.concat([{ label: value, value }]); + } + return filteredOptions; + }, [normalizedOptions, inputValue, filterable, onSearch, filter, creatable]); + + // 键盘导航与展示列表使用同一轮过滤结果。 + const flattenedOptions = useMemo(() => flattenOptions(currentOptions), [currentOptions]); + + /** + * 关闭时关键词已清空,但退出动画尚未结束,继续展示关闭前的列表以避免闪动。 + * 快照仅在面板打开时更新;再次打开使用最新列表,不影响已选值。 + */ const lastVisibleOptionsRef = useRef(currentOptions); useLayoutEffect(() => { if (innerPopupVisible) lastVisibleOptionsRef.current = currentOptions; @@ -313,51 +354,6 @@ const Select = forwardRefWithStatics( toggleIsScrolling, }); - // 处理filter逻辑 - const handleFilter = (value: string) => { - let filteredOptions: SelectOption[] = []; - if (filterable && isFunction(onSearch)) { - return; - } - if (!value) { - setCurrentOptions(tmpPropOptions); - return; - } - - const filterLabels = []; - const filterMethods = (option: SelectOption) => { - if (filter && isFunction(filter)) { - return filter(value, option); - } - const upperValue = value.toUpperCase(); - const searchableText = extractTextFromTNode(option.label); - return searchableText.toUpperCase().includes(upperValue); - }; - - tmpPropOptions?.forEach((option) => { - if (isSelectOptionGroup(option)) { - filteredOptions.push({ - ...option, - children: option.children?.filter((child) => { - if (filterMethods(child)) { - filterLabels.push(child.label); - return true; - } - return false; - }), - }); - } else if (filterMethods(option)) { - filterLabels.push(option.label); - filteredOptions.push(option); - } - }); - const isSameLabelOptionExist = filterLabels.includes(value); - if (creatable && !isSameLabelOptionExist) { - filteredOptions = filteredOptions.concat([{ label: value, value }]); - } - setCurrentOptions(filteredOptions); - }; - // 处理输入框逻辑 const handleInputChange = (value: string, context: SelectInputValueChangeContext) => { if (context.trigger !== 'clear') { @@ -384,13 +380,6 @@ const Select = forwardRefWithStatics( onClear(context); }; - useEffect(() => { - if (typeof inputValue !== 'undefined') { - handleFilter(String(inputValue)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inputValue, tmpPropOptions]); - // 渲染后置图标 const renderSuffixIcon = () => { if (suffixIcon) { diff --git a/packages/components/select/hooks/useOptions.ts b/packages/components/select/hooks/useOptions.ts index 7c77d1c7ae..f4549ef22c 100644 --- a/packages/components/select/hooks/useOptions.ts +++ b/packages/components/select/hooks/useOptions.ts @@ -7,13 +7,13 @@ import { getKeyMapping, getValueToOption } from '../util/helper'; import type { ReactElement, ReactNode } from 'react'; import type { SelectKeysType, SelectOption, SelectOptionGroup, SelectValue, TdOptionProps } from '../type'; -import type { ValueToOption } from '../util/helper'; // 针对分组的相关判断和扁平处理 export function isSelectOptionGroup(option: SelectOption): option is SelectOptionGroup { return !!option && 'group' in option && 'children' in option; } +// 按展示顺序展开分组选项,供键盘导航使用。 export const flattenOptions = (options: SelectOption[] = []) => { const flattened = []; options.forEach((option) => { @@ -30,34 +30,31 @@ export const flattenOptions = (options: SelectOption[] = []) => { type OptionValueType = SelectValue; -// 处理 options 的逻辑 +/** + * 整理完整候选项并维护已选项回显,关键词过滤由 Select 负责。 + * 已选项查询不依赖过滤结果,避免筛选后丢失标签。 + */ function useOptions( keys: SelectKeysType, options: SelectOption[], children: ReactNode, valueType: 'object' | 'value', value: OptionValueType, - reserveKeyword: boolean, ) { - const [valueToOption, setValueToOption] = useState({}); - const [currentOptions, setCurrentOptions] = useState([]); - const [flattenedOptions, setFlattenedOptions] = useState([]); - const [tmpPropOptions, setTmpPropOptions] = useState([]); const [selectedOptions, setSelectedOptions] = useState([]); const { valueKey, labelKey } = useMemo(() => getKeyMapping(keys), [keys]); - useEffect(() => { - setFlattenedOptions(flattenOptions(currentOptions)); - }, [currentOptions]); - // 处理设置 option 的逻辑 - useEffect(() => { + /** + * 同步整理 options / 选项子节点,让过滤直接使用最新数据。 + * 无选项时保留 undefined,以支持自定义 children 渲染。 + */ + const normalizedOptions = useMemo(() => { let transformedOptions = options; const arrayChildren = React.Children.toArray(children); const optionChildren = arrayChildren.filter((v: ReactElement) => v.type === Option || v.type === OptionGroup); const isChildrenFilterable = arrayChildren.length > 0 && optionChildren.length === arrayChildren.length; - if (reserveKeyword && currentOptions.length && isChildrenFilterable) return; if (isChildrenFilterable) { const handlerOptionElement = (v) => { @@ -86,14 +83,19 @@ function useOptions( label: get(option, labelKey), })); } - setCurrentOptions(transformedOptions); - setTmpPropOptions(transformedOptions); - - setValueToOption(getValueToOption(children as ReactElement, options as TdOptionProps[], keys) || {}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, keys, children, reserveKeyword]); - - // 同步 value 对应的 options + return transformedOptions; + }, [options, keys, children, valueKey, labelKey]); + + // 使用完整数据解析已选标签及事件中的选项信息。 + const valueToOption = useMemo( + () => getValueToOption(children as ReactElement, options as TdOptionProps[], keys) || {}, + [children, options, keys], + ); + + /** + * 远程结果可能不包含已选值,因此保留历史选项作为标签回显的兜底。 + * 已选项仍以当前 value 为准,不保留已取消选中的条目。 + */ useEffect(() => { setSelectedOptions((oldSelectedOptions: SelectOption[]) => { const createOptionFromValue = (item: OptionValueType) => { @@ -128,15 +130,9 @@ function useOptions( }, [value, keys, valueType, valueToOption, valueKey, labelKey, setSelectedOptions]); return { - currentOptions, - setCurrentOptions, - tmpPropOptions, - setTmpPropOptions, + normalizedOptions, valueToOption, - setValueToOption, selectedOptions, - setSelectedOptions, - flattenedOptions, }; } diff --git a/packages/tdesign-react/CHANGELOG.en-US.md b/packages/tdesign-react/CHANGELOG.en-US.md index b0c134bc8d..259258bb0e 100644 --- a/packages/tdesign-react/CHANGELOG.en-US.md +++ b/packages/tdesign-react/CHANGELOG.en-US.md @@ -26,6 +26,7 @@ spline: explain - `Form`: Fixed the issue where nested forms lost their numerical values after the `validate` function was triggered, resulting in re-rendering @RylanBot ([#4350](https://github.com/Tencent/tdesign-react/pull/4350)) - `Popup`: Fixed the problem where the popup would not close automatically when the mouse button was held down or clicked right on it after leaving the popup area @RylanBot ([#4287](https://github.com/Tencent/tdesign-react/pull/4287)) - `SelectInput`: Fixed issues caused by adjustments in version `1.18.1`, which led to changes in the DOM structure when using a single-selection option without `filterable` enabled and a custom `valueDisplay` string being used @RylanBot ([#4351](https://github.com/Tencent/tdesign-react/pull/4351)) +- `Select`: Fixed the issue where the complete option list was briefly displayed when closing the panel after selecting an option with `filterable` enabled @RSS1102 ([#4388](https://github.com/Tencent/tdesign-react/pull/4388)) - `Steps`: - Fixed issues with alignment of connection lines and inconsistent distances from the surrounding icons when `layout='vertical'` was used @RylanBot ([common#2670](https://github.com/Tencent/tdesign-common/pull/2670)) - Fixed the connection line thickness inconsistency between the default and selected states @RylanBot ([common#2670](https://github.com/Tencent/tdesign-common/pull/2670)) diff --git a/packages/tdesign-react/CHANGELOG.md b/packages/tdesign-react/CHANGELOG.md index 3ad001cb07..84eda9782e 100644 --- a/packages/tdesign-react/CHANGELOG.md +++ b/packages/tdesign-react/CHANGELOG.md @@ -26,6 +26,7 @@ spline: explain - `Form`: 修复嵌套表单在触发 `validate` 后重渲染导致数值丢失的问题 @RylanBot ([#4350](https://github.com/Tencent/tdesign-react/pull/4350)) - `Popup`: 修复鼠标在浮层上左键长按或右键点击后,移出浮层无法自动关闭的问题 @RylanBot ([#4287](https://github.com/Tencent/tdesign-react/pull/4287)) - `SelectInput`: 修复 `1.18.1` 的调整,导致单选且未开启 `filterable` 时,`valueDisplay` 为自定义字符串导致的 DOM 结构变更问题 @RylanBot ([#4351](https://github.com/Tencent/tdesign-react/pull/4351)) +- `Select`: 修复 `filterable` 选择选项后关闭面板时短暂显示完整列表的问题 @RSS1102 ([#4388](https://github.com/Tencent/tdesign-react/pull/4388)) - `Steps`: - 修复 `layout='vertical'` 时,连接线不对齐和上下图标距离不一致的问题 @RylanBot ([common#2670](https://github.com/Tencent/tdesign-common/pull/2670)) - 修复默认和选中态的连接线粗细不一致的问题 @RylanBot ([common#2670](https://github.com/Tencent/tdesign-common/pull/2670))