Skip to content
Merged
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
16 changes: 16 additions & 0 deletions packages/components/_util/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,19 @@ export function getOffsetTopToContainer(element: HTMLElement, container: HTMLEle
}
return offsetTop;
}

/**
* 保持用户的选择顺序:
* - 保留已有项的原始顺序,将新选择的项追加到末尾
* - 并移除当前值中已不存在的项
* @param previousValues - 上一次的值数组(按照用户选择顺序排列)
* @param currentValues - 当前完整的值数组(顺序可能是任意的)
* @returns 保持用户选择顺序后的数组
*/
export function preserveSelectionOrder<T>(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];
}
89 changes: 89 additions & 0 deletions packages/components/cascader/__tests__/cascader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any[]>([]);
return (
<Cascader
options={options}
value={value}
multiple
checkStrictly
onChange={(val, ctx) => {
onChange(val, ctx);
setValue(val as any[]);
}}
/>
);
};
const { getByText } = render(<TestComponent />);
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<any[]>([]);
return (
<Cascader
options={options}
value={value}
multiple
checkStrictly
valueType="full"
onChange={(val, ctx) => {
onChange(val, ctx);
setValue(val as any[]);
}}
/>
);
};
const { getByText } = render(<TestComponent />);
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),
);
});
});
32 changes: 16 additions & 16 deletions packages/components/cascader/core/effect.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
RylanBot marked this conversation as resolved.

import type { CascaderContextType, TdCascaderProps, TreeNode, TreeNodeModel, TreeNodeValue } from '../interface';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -160,26 +171,15 @@ 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 [];
const res = newValue.splice(index, 1);
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());
Comment thread
RylanBot marked this conversation as resolved.

if (isFunction(onRemove)) {
onRemove({ value: checked, node: node as any });
Expand Down
10 changes: 8 additions & 2 deletions packages/components/tree-select/TreeSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -215,12 +216,17 @@ const TreeSelect = forwardRef<TreeSelectRefType, TreeSelectProps>((originalProps

const handleMultiChange = usePersistFn<TreeProps['onChange']>((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',
},
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down
7 changes: 7 additions & 0 deletions packages/tdesign-react/.changelog/pr-4281.md
Original file line number Diff line number Diff line change
@@ -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))
Loading