From 0813e4058f814c9fdfe5bc1399abebdec745dda6 Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 12 Jun 2026 18:02:45 +0800 Subject: [PATCH 1/6] fix(TreeSelect): preserve user selection order --- packages/components/_util/helper.ts | 16 ++++++++++++++ packages/components/cascader/core/effect.ts | 21 +++++++++---------- .../components/tree-select/TreeSelect.tsx | 10 +++++++-- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/components/_util/helper.ts b/packages/components/_util/helper.ts index 678ee70dac..ecd8fdec40 100644 --- a/packages/components/_util/helper.ts +++ b/packages/components/_util/helper.ts @@ -56,3 +56,19 @@ export function getOffsetTopToContainer(element: HTMLElement, container: HTMLEle } return offsetTop; } + +/** + * 保持用户的选择顺序: + * - 保留已有项的原始顺序,将新选择的项追加到末尾 + * - 并移除当前值中已不存在的项 + * @param previousValues - 上一次的值数组(按照用户选择顺序排列) + * @param currentValues - 当前完整的值数组(顺序可能是任意的) + * @returns 保持用户选择顺序后的数组 + */ +export function preserveSelectionOrder(previousValues: T[], currentValues: T[]): T[] { + const currentSet = new Set(currentValues); + const existingValues = previousValues.filter((v) => currentSet.has(v)); + const existingSet = new Set(existingValues); + const newValues = currentValues.filter((v) => !existingSet.has(v)); + return [...existingValues, ...newValues]; +} diff --git a/packages/components/cascader/core/effect.ts b/packages/components/cascader/core/effect.ts index ca3f57ec68..47b3aca010 100644 --- a/packages/components/cascader/core/effect.ts +++ b/packages/components/cascader/core/effect.ts @@ -1,6 +1,7 @@ import { cloneDeep, isFunction, isNumber } from 'lodash-es'; import { pathToKey } from '@tdesign/common-js/tree-v1/tree-node-model'; +import { preserveSelectionOrder } from '../../_util/helper'; import { getFullPathLabel, getTreeValue } from './helper'; import type { CascaderContextType, TdCascaderProps, TreeNode, TreeNodeModel, TreeNodeValue } from '../interface'; @@ -81,7 +82,8 @@ export function expendClickEffect( * @returns */ export function valueChangeEffect(node: TreeNode, cascaderContext: CascaderContextType) { - const { disabled, max, inputVal, multiple, setVisible, setValue, treeNodes, treeStore, valueType } = cascaderContext; + const { disabled, max, inputVal, multiple, setVisible, setValue, treeNodes, treeStore, value, valueType } = + cascaderContext; if (!node || disabled || node.disabled) { return; @@ -114,11 +116,14 @@ export function valueChangeEffect(node: TreeNode, cascaderContext: CascaderConte setVisible(false, {}); } + const previousValue = (Array.isArray(value) ? value : []) as TreeNodeValue[]; + const orderedChecked = preserveSelectionOrder(previousValue, checked); + // 处理不同数据类型 const resValue = valueType === 'single' - ? checked - : checked.map((val) => + ? orderedChecked + : orderedChecked.map((val) => treeStore .getNode(val) .getPath() @@ -171,14 +176,8 @@ export function handleRemoveTagEffect( if (valueType === 'single') { setValue(newValue, 'uncheck', node.getModel()); } else { - // 处理不同数据类型 - const resValue = checked.map((val) => - treeStore - .getNode(val) - .getPath() - .map((item) => item.value), - ); - setValue(resValue, 'uncheck', node.getModel()); + // Preserve user selection order: use newValue which already has the item removed at the correct index + setValue(newValue, 'uncheck', node.getModel()); } if (isFunction(onRemove)) { diff --git a/packages/components/tree-select/TreeSelect.tsx b/packages/components/tree-select/TreeSelect.tsx index 562377e2ed..ea48a6ff64 100644 --- a/packages/components/tree-select/TreeSelect.tsx +++ b/packages/components/tree-select/TreeSelect.tsx @@ -2,6 +2,7 @@ import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } import classNames from 'classnames'; import { isFunction } from 'lodash-es'; +import { preserveSelectionOrder } from '../_util/helper'; import noop from '../_util/noop'; import parseTNode from '../_util/parseTNode'; import useConfig from '../hooks/useConfig'; @@ -215,12 +216,17 @@ const TreeSelect = forwardRef((originalProps const handleMultiChange = usePersistFn((value, context) => { if (max === 0 || value.length <= max) { + const isCheck = value.length > normalizedValue.length; + const orderedValues = preserveSelectionOrder( + normalizedValue.map(({ value }) => value), + value, + ); onChange( - value.map((value) => formatValue(value, getNodeItem(value)?.label)), + orderedValues.map((value) => formatValue(value, getNodeItem(value)?.label)), { ...context, data: context.node.data, - trigger: value.length > normalizedValue.length ? 'check' : 'uncheck', + trigger: isCheck ? 'check' : 'uncheck', }, ); } From e3e7e32b53987f93ceee4103d72286a1b0063ab2 Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 12 Jun 2026 18:04:19 +0800 Subject: [PATCH 2/6] chore: remove useless code --- packages/components/cascader/core/effect.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/components/cascader/core/effect.ts b/packages/components/cascader/core/effect.ts index 47b3aca010..e705b600c6 100644 --- a/packages/components/cascader/core/effect.ts +++ b/packages/components/cascader/core/effect.ts @@ -165,7 +165,7 @@ export function handleRemoveTagEffect( index: number, onRemove: TdCascaderProps['onRemove'], ) { - const { disabled, setValue, value, valueType, treeStore } = cascaderContext; + const { disabled, setValue, value, treeStore } = cascaderContext; if (disabled) return; const newValue = cloneDeep(value) as []; @@ -173,12 +173,7 @@ export function handleRemoveTagEffect( const node = treeStore.getNodes(res[0])[0]; const checked = node.setChecked(!node.isChecked()); - if (valueType === 'single') { - setValue(newValue, 'uncheck', node.getModel()); - } else { - // Preserve user selection order: use newValue which already has the item removed at the correct index - setValue(newValue, 'uncheck', node.getModel()); - } + setValue(newValue, 'uncheck', node.getModel()); if (isFunction(onRemove)) { onRemove({ value: checked, node: node as any }); From 635a7d1ade51ab71e1dc1eb321cc392958c6fb05 Mon Sep 17 00:00:00 2001 From: Rylan Date: Mon, 15 Jun 2026 10:49:38 +0800 Subject: [PATCH 3/6] fix: update test logic --- .../tree-select/__tests__/vitest-tree-select.test.jsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/components/tree-select/__tests__/vitest-tree-select.test.jsx b/packages/components/tree-select/__tests__/vitest-tree-select.test.jsx index ae5d317782..15478ed9bb 100644 --- a/packages/components/tree-select/__tests__/vitest-tree-select.test.jsx +++ b/packages/components/tree-select/__tests__/vitest-tree-select.test.jsx @@ -7,13 +7,14 @@ import React from 'react'; import { fireEvent, - vi, - render, mockDelay, - simulateInputChange, + render, simulateDocumentMouseEvent, + simulateInputChange, simulateInputEnter, + vi, } from '@test/utils'; + import { TreeSelect } from '..'; import { getTreeSelectDefaultMount, getTreeSelectMultipleMount } from './mount'; @@ -474,7 +475,7 @@ describe('TreeSelect Component', () => { await mockDelay(200); fireEvent.click(document.querySelector('.t-tree__item:last-child .t-checkbox__label')); expect(onChangeFn1).toHaveBeenCalled(); - expect(onChangeFn1.mock.calls[0][0]).toEqual([1, '2.1', '2.2', 3, '4', '5', '6']); + expect(onChangeFn1.mock.calls[0][0]).toEqual([1, 3, '4', '5', '2.1', '2.2', '6']); expect(onChangeFn1.mock.calls[0][1].trigger).toBe('check'); expect(onChangeFn1.mock.calls[0][1].e.type).toBe('change'); expect(onChangeFn1.mock.calls[0][1].node.label).toBe('tdesign-mobile-vue'); @@ -492,7 +493,7 @@ describe('TreeSelect Component', () => { await mockDelay(200); fireEvent.click(document.querySelector('.t-tree__item:first-child .t-checkbox__label')); expect(onChangeFn1).toHaveBeenCalled(); - expect(onChangeFn1.mock.calls[0][0]).toEqual(['2.1', '2.2', 3, '4', '5']); + expect(onChangeFn1.mock.calls[0][0]).toEqual([3, '4', '5', '2.1', '2.2']); expect(onChangeFn1.mock.calls[0][1].trigger).toBe('uncheck'); expect(onChangeFn1.mock.calls[0][1].e.type).toBe('change'); expect(onChangeFn1.mock.calls[0][1].node.label).toBe('tdesign-vue'); From 100e99da5c7b64ab36c734d141973b5ca8702957 Mon Sep 17 00:00:00 2001 From: Rylan Date: Thu, 25 Jun 2026 17:34:22 +0800 Subject: [PATCH 4/6] fix: valueType === 'full' --- packages/components/cascader/core/effect.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/components/cascader/core/effect.ts b/packages/components/cascader/core/effect.ts index e705b600c6..185bbf8fe4 100644 --- a/packages/components/cascader/core/effect.ts +++ b/packages/components/cascader/core/effect.ts @@ -116,8 +116,14 @@ export function valueChangeEffect(node: TreeNode, cascaderContext: CascaderConte setVisible(false, {}); } - const previousValue = (Array.isArray(value) ? value : []) as TreeNodeValue[]; - const orderedChecked = preserveSelectionOrder(previousValue, checked); + const getPreviousKeys = (): TreeNodeValue[] => { + if (!Array.isArray(value)) return []; + if (valueType === 'full') { + return (value as TreeNodeValue[][]).filter((path) => Array.isArray(path) && path.length > 0).map(pathToKey); + } + return value as TreeNodeValue[]; + }; + const orderedChecked = preserveSelectionOrder(getPreviousKeys(), checked); // 处理不同数据类型 const resValue = From 962c176afddcf5746e15a1f1f091a796f33cf8e6 Mon Sep 17 00:00:00 2001 From: Rylan Date: Thu, 25 Jun 2026 17:38:44 +0800 Subject: [PATCH 5/6] chore: add test for cascader --- .../cascader/__tests__/cascader.test.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/components/cascader/__tests__/cascader.test.tsx b/packages/components/cascader/__tests__/cascader.test.tsx index faf476c929..39b8985958 100644 --- a/packages/components/cascader/__tests__/cascader.test.tsx +++ b/packages/components/cascader/__tests__/cascader.test.tsx @@ -484,4 +484,93 @@ describe('Cascader Panel 组件测试', () => { expect(getByTitle('子选项一')).toHaveClass('t-is-checked'); expect(getByTitle('选项二')).toHaveClass('t-is-disabled'); }); + + test('multiple 应按用户勾选顺序保留 value', async () => { + const onChange = vi.fn(); + const TestComponent = () => { + const [value, setValue] = useState([]); + return ( + { + onChange(val, ctx); + setValue(val as any[]); + }} + /> + ); + }; + const { getByText } = render(); + fireEvent.click(document.querySelector('input')); + await mockDelay(); + + // 切到「选项二」面板,勾选 2.1 + fireEvent.click(getByText('选项二')); + fireEvent.click(document.querySelector('.t-checkbox input[name="2.1"]')); + expect(onChange).toHaveBeenLastCalledWith(['2.1'], expect.any(Object)); + + // 切到「选项一」面板,勾选 1.1(最新点击的项应排在末尾) + fireEvent.click(getByText('选项一')); + fireEvent.click(document.querySelector('.t-checkbox input[name="1.1"]')); + expect(onChange).toHaveBeenLastCalledWith(['2.1', '1.1'], expect.any(Object)); + + // 继续切到「选项二」勾选 2.2 + fireEvent.click(getByText('选项二')); + fireEvent.click(document.querySelector('.t-checkbox input[name="2.2"]')); + expect(onChange).toHaveBeenLastCalledWith(['2.1', '1.1', '2.2'], expect.any(Object)); + + // 取消勾选 2.1,已有项的相对顺序应保留 + fireEvent.click(document.querySelector('.t-checkbox input[name="2.1"]')); + expect(onChange).toHaveBeenLastCalledWith(['1.1', '2.2'], expect.any(Object)); + }); + + test('multiple + valueType=full 应按用户勾选顺序保留 value', async () => { + const onChange = vi.fn(); + const TestComponent = () => { + const [value, setValue] = useState([]); + return ( + { + onChange(val, ctx); + setValue(val as any[]); + }} + /> + ); + }; + const { getByText } = render(); + fireEvent.click(document.querySelector('input')); + await mockDelay(); + + fireEvent.click(getByText('选项二')); + fireEvent.click(document.querySelector('.t-checkbox input[name="2.1"]')); + expect(onChange).toHaveBeenLastCalledWith([['2', '2.1']], expect.any(Object)); + + fireEvent.click(getByText('选项一')); + fireEvent.click(document.querySelector('.t-checkbox input[name="1.1"]')); + expect(onChange).toHaveBeenLastCalledWith( + [ + ['2', '2.1'], + ['1', '1.1'], + ], + expect.any(Object), + ); + + fireEvent.click(getByText('选项二')); + fireEvent.click(document.querySelector('.t-checkbox input[name="2.2"]')); + expect(onChange).toHaveBeenLastCalledWith( + [ + ['2', '2.1'], + ['1', '1.1'], + ['2', '2.2'], + ], + expect.any(Object), + ); + }); }); From 71b7f47eea290f5a3aed1683e78bf7b2b1103604 Mon Sep 17 00:00:00 2001 From: tdesign-bot Date: Fri, 26 Jun 2026 03:47:03 +0000 Subject: [PATCH 6/6] chore: stash changelog [ci skip] --- packages/tdesign-react/.changelog/pr-4281.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/tdesign-react/.changelog/pr-4281.md diff --git a/packages/tdesign-react/.changelog/pr-4281.md b/packages/tdesign-react/.changelog/pr-4281.md new file mode 100644 index 0000000000..62f5de0874 --- /dev/null +++ b/packages/tdesign-react/.changelog/pr-4281.md @@ -0,0 +1,7 @@ +--- +pr_number: 4281 +contributor: RylanBot +--- + +- fix(TreeSelect): 多选时输入框内的选中项顺序由 “`options` 顺序” 调整为 “用户选择顺序“,之前依赖相关交互的业务注意此变更 ⚠️ @RylanBot ([#4281](https://github.com/Tencent/tdesign-react/pull/4281)) +- fix(Cascader): 多选时输入框内的选中项顺序由 “`options` 顺序” 调整为 “用户选择顺序“,之前依赖相关交互的业务注意此变更 ⚠️ @RylanBot ([#4281](https://github.com/Tencent/tdesign-react/pull/4281))