Skip to content

Commit a359516

Browse files
fix(TreeSelect): preserve user selection order (#4281)
* fix(TreeSelect): preserve user selection order * chore: remove useless code * fix: update test logic * fix: valueType === 'full' * chore: add test for cascader * chore: stash changelog [ci skip] --------- Co-authored-by: tdesign-bot <tdesign@tencent.com>
1 parent 40729ac commit a359516

6 files changed

Lines changed: 142 additions & 23 deletions

File tree

packages/components/_util/helper.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,19 @@ export function getOffsetTopToContainer(element: HTMLElement, container: HTMLEle
5656
}
5757
return offsetTop;
5858
}
59+
60+
/**
61+
* 保持用户的选择顺序:
62+
* - 保留已有项的原始顺序,将新选择的项追加到末尾
63+
* - 并移除当前值中已不存在的项
64+
* @param previousValues - 上一次的值数组(按照用户选择顺序排列)
65+
* @param currentValues - 当前完整的值数组(顺序可能是任意的)
66+
* @returns 保持用户选择顺序后的数组
67+
*/
68+
export function preserveSelectionOrder<T>(previousValues: T[], currentValues: T[]): T[] {
69+
const currentSet = new Set(currentValues);
70+
const existingValues = previousValues.filter((v) => currentSet.has(v));
71+
const existingSet = new Set(existingValues);
72+
const newValues = currentValues.filter((v) => !existingSet.has(v));
73+
return [...existingValues, ...newValues];
74+
}

packages/components/cascader/__tests__/cascader.test.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,4 +484,93 @@ describe('Cascader Panel 组件测试', () => {
484484
expect(getByTitle('子选项一')).toHaveClass('t-is-checked');
485485
expect(getByTitle('选项二')).toHaveClass('t-is-disabled');
486486
});
487+
488+
test('multiple 应按用户勾选顺序保留 value', async () => {
489+
const onChange = vi.fn();
490+
const TestComponent = () => {
491+
const [value, setValue] = useState<any[]>([]);
492+
return (
493+
<Cascader
494+
options={options}
495+
value={value}
496+
multiple
497+
checkStrictly
498+
onChange={(val, ctx) => {
499+
onChange(val, ctx);
500+
setValue(val as any[]);
501+
}}
502+
/>
503+
);
504+
};
505+
const { getByText } = render(<TestComponent />);
506+
fireEvent.click(document.querySelector('input'));
507+
await mockDelay();
508+
509+
// 切到「选项二」面板,勾选 2.1
510+
fireEvent.click(getByText('选项二'));
511+
fireEvent.click(document.querySelector('.t-checkbox input[name="2.1"]'));
512+
expect(onChange).toHaveBeenLastCalledWith(['2.1'], expect.any(Object));
513+
514+
// 切到「选项一」面板,勾选 1.1(最新点击的项应排在末尾)
515+
fireEvent.click(getByText('选项一'));
516+
fireEvent.click(document.querySelector('.t-checkbox input[name="1.1"]'));
517+
expect(onChange).toHaveBeenLastCalledWith(['2.1', '1.1'], expect.any(Object));
518+
519+
// 继续切到「选项二」勾选 2.2
520+
fireEvent.click(getByText('选项二'));
521+
fireEvent.click(document.querySelector('.t-checkbox input[name="2.2"]'));
522+
expect(onChange).toHaveBeenLastCalledWith(['2.1', '1.1', '2.2'], expect.any(Object));
523+
524+
// 取消勾选 2.1,已有项的相对顺序应保留
525+
fireEvent.click(document.querySelector('.t-checkbox input[name="2.1"]'));
526+
expect(onChange).toHaveBeenLastCalledWith(['1.1', '2.2'], expect.any(Object));
527+
});
528+
529+
test('multiple + valueType=full 应按用户勾选顺序保留 value', async () => {
530+
const onChange = vi.fn();
531+
const TestComponent = () => {
532+
const [value, setValue] = useState<any[]>([]);
533+
return (
534+
<Cascader
535+
options={options}
536+
value={value}
537+
multiple
538+
checkStrictly
539+
valueType="full"
540+
onChange={(val, ctx) => {
541+
onChange(val, ctx);
542+
setValue(val as any[]);
543+
}}
544+
/>
545+
);
546+
};
547+
const { getByText } = render(<TestComponent />);
548+
fireEvent.click(document.querySelector('input'));
549+
await mockDelay();
550+
551+
fireEvent.click(getByText('选项二'));
552+
fireEvent.click(document.querySelector('.t-checkbox input[name="2.1"]'));
553+
expect(onChange).toHaveBeenLastCalledWith([['2', '2.1']], expect.any(Object));
554+
555+
fireEvent.click(getByText('选项一'));
556+
fireEvent.click(document.querySelector('.t-checkbox input[name="1.1"]'));
557+
expect(onChange).toHaveBeenLastCalledWith(
558+
[
559+
['2', '2.1'],
560+
['1', '1.1'],
561+
],
562+
expect.any(Object),
563+
);
564+
565+
fireEvent.click(getByText('选项二'));
566+
fireEvent.click(document.querySelector('.t-checkbox input[name="2.2"]'));
567+
expect(onChange).toHaveBeenLastCalledWith(
568+
[
569+
['2', '2.1'],
570+
['1', '1.1'],
571+
['2', '2.2'],
572+
],
573+
expect.any(Object),
574+
);
575+
});
487576
});

packages/components/cascader/core/effect.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { cloneDeep, isFunction, isNumber } from 'lodash-es';
22
import { pathToKey } from '@tdesign/common-js/tree-v1/tree-node-model';
33

4+
import { preserveSelectionOrder } from '../../_util/helper';
45
import { getFullPathLabel, getTreeValue } from './helper';
56

67
import type { CascaderContextType, TdCascaderProps, TreeNode, TreeNodeModel, TreeNodeValue } from '../interface';
@@ -81,7 +82,8 @@ export function expendClickEffect(
8182
* @returns
8283
*/
8384
export function valueChangeEffect(node: TreeNode, cascaderContext: CascaderContextType) {
84-
const { disabled, max, inputVal, multiple, setVisible, setValue, treeNodes, treeStore, valueType } = cascaderContext;
85+
const { disabled, max, inputVal, multiple, setVisible, setValue, treeNodes, treeStore, value, valueType } =
86+
cascaderContext;
8587

8688
if (!node || disabled || node.disabled) {
8789
return;
@@ -114,11 +116,20 @@ export function valueChangeEffect(node: TreeNode, cascaderContext: CascaderConte
114116
setVisible(false, {});
115117
}
116118

119+
const getPreviousKeys = (): TreeNodeValue[] => {
120+
if (!Array.isArray(value)) return [];
121+
if (valueType === 'full') {
122+
return (value as TreeNodeValue[][]).filter((path) => Array.isArray(path) && path.length > 0).map(pathToKey);
123+
}
124+
return value as TreeNodeValue[];
125+
};
126+
const orderedChecked = preserveSelectionOrder(getPreviousKeys(), checked);
127+
117128
// 处理不同数据类型
118129
const resValue =
119130
valueType === 'single'
120-
? checked
121-
: checked.map((val) =>
131+
? orderedChecked
132+
: orderedChecked.map((val) =>
122133
treeStore
123134
.getNode(val)
124135
.getPath()
@@ -160,26 +171,15 @@ export function handleRemoveTagEffect(
160171
index: number,
161172
onRemove: TdCascaderProps['onRemove'],
162173
) {
163-
const { disabled, setValue, value, valueType, treeStore } = cascaderContext;
174+
const { disabled, setValue, value, treeStore } = cascaderContext;
164175

165176
if (disabled) return;
166177
const newValue = cloneDeep(value) as [];
167178
const res = newValue.splice(index, 1);
168179
const node = treeStore.getNodes(res[0])[0];
169180
const checked = node.setChecked(!node.isChecked());
170181

171-
if (valueType === 'single') {
172-
setValue(newValue, 'uncheck', node.getModel());
173-
} else {
174-
// 处理不同数据类型
175-
const resValue = checked.map((val) =>
176-
treeStore
177-
.getNode(val)
178-
.getPath()
179-
.map((item) => item.value),
180-
);
181-
setValue(resValue, 'uncheck', node.getModel());
182-
}
182+
setValue(newValue, 'uncheck', node.getModel());
183183

184184
if (isFunction(onRemove)) {
185185
onRemove({ value: checked, node: node as any });

packages/components/tree-select/TreeSelect.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef }
22
import classNames from 'classnames';
33
import { isFunction } from 'lodash-es';
44

5+
import { preserveSelectionOrder } from '../_util/helper';
56
import noop from '../_util/noop';
67
import parseTNode from '../_util/parseTNode';
78
import useConfig from '../hooks/useConfig';
@@ -217,12 +218,17 @@ const TreeSelect = forwardRef<TreeSelectRefType, TreeSelectProps>((originalProps
217218

218219
const handleMultiChange = usePersistFn<TreeProps['onChange']>((value, context) => {
219220
if (max === 0 || value.length <= max) {
221+
const isCheck = value.length > normalizedValue.length;
222+
const orderedValues = preserveSelectionOrder(
223+
normalizedValue.map(({ value }) => value),
224+
value,
225+
);
220226
onChange(
221-
value.map((value) => formatValue(value, getNodeItem(value)?.label)),
227+
orderedValues.map((value) => formatValue(value, getNodeItem(value)?.label)),
222228
{
223229
...context,
224230
data: context.node.data,
225-
trigger: value.length > normalizedValue.length ? 'check' : 'uncheck',
231+
trigger: isCheck ? 'check' : 'uncheck',
226232
},
227233
);
228234
}

packages/components/tree-select/__tests__/vitest-tree-select.test.jsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
import React from 'react';
88
import {
99
fireEvent,
10-
vi,
11-
render,
1210
mockDelay,
13-
simulateInputChange,
11+
render,
1412
simulateDocumentMouseEvent,
13+
simulateInputChange,
1514
simulateInputEnter,
15+
vi,
1616
} from '@test/utils';
17+
1718
import { TreeSelect } from '..';
1819
import { getTreeSelectDefaultMount, getTreeSelectMultipleMount } from './mount';
1920

@@ -474,7 +475,7 @@ describe('TreeSelect Component', () => {
474475
await mockDelay(200);
475476
fireEvent.click(document.querySelector('.t-tree__item:last-child .t-checkbox__label'));
476477
expect(onChangeFn1).toHaveBeenCalled();
477-
expect(onChangeFn1.mock.calls[0][0]).toEqual([1, '2.1', '2.2', 3, '4', '5', '6']);
478+
expect(onChangeFn1.mock.calls[0][0]).toEqual([1, 3, '4', '5', '2.1', '2.2', '6']);
478479
expect(onChangeFn1.mock.calls[0][1].trigger).toBe('check');
479480
expect(onChangeFn1.mock.calls[0][1].e.type).toBe('change');
480481
expect(onChangeFn1.mock.calls[0][1].node.label).toBe('tdesign-mobile-vue');
@@ -492,7 +493,7 @@ describe('TreeSelect Component', () => {
492493
await mockDelay(200);
493494
fireEvent.click(document.querySelector('.t-tree__item:first-child .t-checkbox__label'));
494495
expect(onChangeFn1).toHaveBeenCalled();
495-
expect(onChangeFn1.mock.calls[0][0]).toEqual(['2.1', '2.2', 3, '4', '5']);
496+
expect(onChangeFn1.mock.calls[0][0]).toEqual([3, '4', '5', '2.1', '2.2']);
496497
expect(onChangeFn1.mock.calls[0][1].trigger).toBe('uncheck');
497498
expect(onChangeFn1.mock.calls[0][1].e.type).toBe('change');
498499
expect(onChangeFn1.mock.calls[0][1].node.label).toBe('tdesign-vue');
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
pr_number: 4281
3+
contributor: RylanBot
4+
---
5+
6+
- fix(TreeSelect): 多选时输入框内的选中项顺序由 “`options` 顺序” 调整为 “用户选择顺序“,之前依赖相关交互的业务注意此变更 ⚠️ @RylanBot ([#4281](https://github.com/Tencent/tdesign-react/pull/4281))
7+
- fix(Cascader): 多选时输入框内的选中项顺序由 “`options` 顺序” 调整为 “用户选择顺序“,之前依赖相关交互的业务注意此变更 ⚠️ @RylanBot ([#4281](https://github.com/Tencent/tdesign-react/pull/4281))

0 commit comments

Comments
 (0)