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/__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), + ); + }); }); diff --git a/packages/components/cascader/core/effect.ts b/packages/components/cascader/core/effect.ts index ca3f57ec68..185bbf8fe4 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,20 @@ export function valueChangeEffect(node: TreeNode, cascaderContext: CascaderConte setVisible(false, {}); } + 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 = valueType === 'single' - ? checked - : checked.map((val) => + ? orderedChecked + : orderedChecked.map((val) => treeStore .getNode(val) .getPath() @@ -160,7 +171,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 []; @@ -168,18 +179,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 { - // 处理不同数据类型 - const resValue = checked.map((val) => - treeStore - .getNode(val) - .getPath() - .map((item) => item.value), - ); - setValue(resValue, 'uncheck', node.getModel()); - } + setValue(newValue, 'uncheck', node.getModel()); if (isFunction(onRemove)) { onRemove({ value: checked, node: node as any }); 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', }, ); } 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'); 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))