Skip to content

Commit b961a86

Browse files
authored
feat: TreeSelect support maxCount (#596)
* feat: TreeSelect support maxCount * feat: sync activeKey state * feat: sync disabled state * feat: sync disabled state * docs: improve maxCount demo * test: add maxCount test cases * chore: remove deadCode * test: add test cases for keyboard operations * chore: remove useless code * test: add test case * test: improve test case * docs: add maxCount description * feat: forbid check when checkedKeys more than maxCount * chore: demo improvement * feat: adjust maxCount implement logic * fix: lint fix * test: add test cases for maxCount * chore: hoist state to context * chore: hoist traverse operation to TreeSelect * feat: improve keyboard navigation when reach maxCount * feat: improve keyboard navigation when reach maxCount * perf: use cache to improve navigation performance * refactor: reuse formatStrategyValues * feat: add disabledStrategy * feat: add code comment * test: supplement test case for keyboard operation * chore: handle git conflicts manually * chore: remove useless code * chore: memories displayValues * refactor: use InternalContext * chore: adjust context api * chore: bump rc-tree version to 5.11.0 for support maxCount * fix: test coverage * fix: fix some case * chore: remove keyboard operation logic * chore: optimized code logic
1 parent c3bf3cb commit b961a86

13 files changed

+777
-177
lines changed

README.md

Lines changed: 65 additions & 65 deletions
Large diffs are not rendered by default.

docs/demo/mutiple-with-maxCount.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
title: mutiple-with-maxCount
3+
nav:
4+
title: Demo
5+
path: /demo
6+
---
7+
8+
<code src="../../examples/mutiple-with-maxCount.tsx"></code>

examples/mutiple-with-maxCount.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React, { useState } from 'react';
2+
import TreeSelect from '../src';
3+
4+
export default () => {
5+
const [value, setValue] = useState<string[]>(['1']);
6+
const [checkValue, setCheckValue] = useState<string[]>(['1']);
7+
8+
const treeData = [
9+
{
10+
key: '1',
11+
value: '1',
12+
title: '1',
13+
children: [
14+
{
15+
key: '1-1',
16+
value: '1-1',
17+
title: '1-1',
18+
},
19+
{
20+
key: '1-2',
21+
value: '1-2',
22+
title: '1-2',
23+
},
24+
{
25+
key: '1-3',
26+
value: '1-3',
27+
title: '1-3',
28+
},
29+
],
30+
},
31+
{
32+
key: '2',
33+
value: '2',
34+
title: '2',
35+
},
36+
{
37+
key: '3',
38+
value: '3',
39+
title: '3',
40+
},
41+
{
42+
key: '4',
43+
value: '4',
44+
title: '4',
45+
},
46+
];
47+
48+
const onChange = (val: string[]) => {
49+
setValue(val);
50+
};
51+
52+
const onCheckChange = (val: string[]) => {
53+
setCheckValue(val);
54+
};
55+
56+
return (
57+
<>
58+
<h2>multiple with maxCount</h2>
59+
<TreeSelect
60+
style={{ width: 300 }}
61+
fieldNames={{ value: 'value', label: 'title' }}
62+
multiple
63+
maxCount={3}
64+
treeData={treeData}
65+
/>
66+
67+
<h2>checkable with maxCount</h2>
68+
<TreeSelect
69+
style={{ width: 300 }}
70+
multiple
71+
treeCheckable
72+
// showCheckedStrategy="SHOW_ALL"
73+
showCheckedStrategy="SHOW_PARENT"
74+
// showCheckedStrategy="SHOW_CHILD"
75+
maxCount={4}
76+
treeData={treeData}
77+
onChange={onChange}
78+
value={value}
79+
/>
80+
81+
<h2>checkable with maxCount and treeCheckStrictly</h2>
82+
<TreeSelect
83+
style={{ width: 300 }}
84+
multiple
85+
treeCheckable
86+
treeCheckStrictly
87+
maxCount={3}
88+
treeData={treeData}
89+
onChange={onCheckChange}
90+
value={checkValue}
91+
/>
92+
</>
93+
);
94+
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"@babel/runtime": "^7.25.7",
4848
"classnames": "2.x",
4949
"rc-select": "~14.16.2",
50-
"rc-tree": "~5.10.1",
50+
"rc-tree": "~5.11.0",
5151
"rc-util": "^5.43.0"
5252
},
5353
"devDependencies": {

src/OptionList.tsx

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import { useBaseProps } from 'rc-select';
22
import type { RefOptionListProps } from 'rc-select/lib/OptionList';
33
import type { TreeProps } from 'rc-tree';
44
import Tree from 'rc-tree';
5+
import { UnstableContext } from 'rc-tree';
56
import type { EventDataNode, ScrollTo } from 'rc-tree/lib/interface';
67
import KeyCode from 'rc-util/lib/KeyCode';
78
import useMemo from 'rc-util/lib/hooks/useMemo';
89
import * as React from 'react';
910
import LegacyContext from './LegacyContext';
1011
import TreeSelectContext from './TreeSelectContext';
11-
import type { Key, SafeKey } from './interface';
12+
import type { DataNode, Key, SafeKey } from './interface';
1213
import { getAllKeys, isCheckDisabled } from './utils/valueUtil';
14+
import { useEvent } from 'rc-util';
1315

1416
const HIDDEN_STYLE = {
1517
width: 0,
@@ -45,6 +47,8 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
4547
treeExpandAction,
4648
treeTitleRender,
4749
onPopupScroll,
50+
displayValues,
51+
isOverMaxCount,
4852
} = React.useContext(TreeSelectContext);
4953

5054
const {
@@ -76,6 +80,11 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
7680
(prev, next) => next[0] && prev[1] !== next[1],
7781
);
7882

83+
const memoRawValues = React.useMemo(
84+
() => (displayValues || []).map(v => v.value),
85+
[displayValues],
86+
);
87+
7988
// ========================== Values ==========================
8089
const mergedCheckedKeys = React.useMemo(() => {
8190
if (!checkable) {
@@ -154,6 +163,10 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
154163
// eslint-disable-next-line react-hooks/exhaustive-deps
155164
}, [searchValue]);
156165

166+
const nodeDisabled = useEvent((node: DataNode) => {
167+
return isOverMaxCount && !memoRawValues.includes(node[fieldNames.value]);
168+
});
169+
157170
// ========================== Get First Selectable Node ==========================
158171
const getFirstMatchingNode = (nodes: EventDataNode<any>[]): EventDataNode<any> | null => {
159172
for (const node of nodes) {
@@ -221,8 +234,9 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
221234
// >>> Select item
222235
case KeyCode.ENTER: {
223236
if (activeEntity) {
237+
const isNodeDisabled = nodeDisabled(activeEntity.node);
224238
const { selectable, value, disabled } = activeEntity?.node || {};
225-
if (selectable !== false && !disabled) {
239+
if (selectable !== false && !disabled && !isNodeDisabled) {
226240
onInternalSelect(null, {
227241
node: { key: activeKey },
228242
selected: !checkedKeys.includes(value),
@@ -276,42 +290,43 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
276290
{activeEntity.node.value}
277291
</span>
278292
)}
279-
280-
<Tree
281-
ref={treeRef}
282-
focusable={false}
283-
prefixCls={`${prefixCls}-tree`}
284-
treeData={memoTreeData}
285-
height={listHeight}
286-
itemHeight={listItemHeight}
287-
itemScrollOffset={listItemScrollOffset}
288-
virtual={virtual !== false && dropdownMatchSelectWidth !== false}
289-
multiple={multiple}
290-
icon={treeIcon}
291-
showIcon={showTreeIcon}
292-
switcherIcon={switcherIcon}
293-
showLine={treeLine}
294-
loadData={syncLoadData}
295-
motion={treeMotion}
296-
activeKey={activeKey}
297-
// We handle keys by out instead tree self
298-
checkable={checkable}
299-
checkStrictly
300-
checkedKeys={mergedCheckedKeys}
301-
selectedKeys={!checkable ? checkedKeys : []}
302-
defaultExpandAll={treeDefaultExpandAll}
303-
titleRender={treeTitleRender}
304-
{...treeProps}
305-
// Proxy event out
306-
onActiveChange={setActiveKey}
307-
onSelect={onInternalSelect}
308-
onCheck={onInternalSelect}
309-
onExpand={onInternalExpand}
310-
onLoad={onTreeLoad}
311-
filterTreeNode={filterTreeNode}
312-
expandAction={treeExpandAction}
313-
onScroll={onPopupScroll}
314-
/>
293+
<UnstableContext.Provider value={{ nodeDisabled }}>
294+
<Tree
295+
ref={treeRef}
296+
focusable={false}
297+
prefixCls={`${prefixCls}-tree`}
298+
treeData={memoTreeData}
299+
height={listHeight}
300+
itemHeight={listItemHeight}
301+
itemScrollOffset={listItemScrollOffset}
302+
virtual={virtual !== false && dropdownMatchSelectWidth !== false}
303+
multiple={multiple}
304+
icon={treeIcon}
305+
showIcon={showTreeIcon}
306+
switcherIcon={switcherIcon}
307+
showLine={treeLine}
308+
loadData={syncLoadData}
309+
motion={treeMotion}
310+
activeKey={activeKey}
311+
// We handle keys by out instead tree self
312+
checkable={checkable}
313+
checkStrictly
314+
checkedKeys={mergedCheckedKeys}
315+
selectedKeys={!checkable ? checkedKeys : []}
316+
defaultExpandAll={treeDefaultExpandAll}
317+
titleRender={treeTitleRender}
318+
{...treeProps}
319+
// Proxy event out
320+
onActiveChange={setActiveKey}
321+
onSelect={onInternalSelect}
322+
onCheck={onInternalSelect}
323+
onExpand={onInternalExpand}
324+
onLoad={onTreeLoad}
325+
filterTreeNode={filterTreeNode}
326+
expandAction={treeExpandAction}
327+
onScroll={onPopupScroll}
328+
/>
329+
</UnstableContext.Provider>
315330
</div>
316331
);
317332
};

src/TreeSelect.tsx

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export interface TreeSelectProps<ValueType = any, OptionType extends DataNode =
7272
treeCheckable?: boolean | React.ReactNode;
7373
treeCheckStrictly?: boolean;
7474
labelInValue?: boolean;
75+
maxCount?: number;
7576

7677
// >>> Data
7778
treeData?: OptionType[];
@@ -136,6 +137,7 @@ const TreeSelect = React.forwardRef<BaseSelectRef, TreeSelectProps>((props, ref)
136137
treeCheckable,
137138
treeCheckStrictly,
138139
labelInValue,
140+
maxCount,
139141

140142
// FieldNames
141143
fieldNames,
@@ -413,6 +415,20 @@ const TreeSelect = React.forwardRef<BaseSelectRef, TreeSelectProps>((props, ref)
413415
extra: { triggerValue?: SafeKey; selected?: boolean },
414416
source: SelectSource,
415417
) => {
418+
const formattedKeyList = formatStrategyValues(
419+
newRawValues,
420+
mergedShowCheckedStrategy,
421+
keyEntities,
422+
mergedFieldNames,
423+
);
424+
425+
// if multiple and maxCount is set, check if exceed maxCount
426+
if (mergedMultiple && maxCount !== undefined) {
427+
if (formattedKeyList.length > maxCount) {
428+
return;
429+
}
430+
}
431+
416432
const labeledValues = convert2LabelValues(newRawValues);
417433
setInternalValue(labeledValues);
418434

@@ -425,12 +441,6 @@ const TreeSelect = React.forwardRef<BaseSelectRef, TreeSelectProps>((props, ref)
425441
if (onChange) {
426442
let eventValues: SafeKey[] = newRawValues;
427443
if (treeConduction) {
428-
const formattedKeyList = formatStrategyValues(
429-
newRawValues,
430-
mergedShowCheckedStrategy,
431-
keyEntities,
432-
mergedFieldNames,
433-
);
434444
eventValues = formattedKeyList.map(key => {
435445
const entity = valueEntities.get(key);
436446
return entity ? entity.node[mergedFieldNames.value] : key;
@@ -558,6 +568,7 @@ const TreeSelect = React.forwardRef<BaseSelectRef, TreeSelectProps>((props, ref)
558568
onDeselect,
559569
rawCheckedValues,
560570
rawHalfCheckedValues,
571+
maxCount,
561572
],
562573
);
563574

@@ -596,8 +607,11 @@ const TreeSelect = React.forwardRef<BaseSelectRef, TreeSelectProps>((props, ref)
596607
});
597608

598609
// ========================== Context ===========================
599-
const treeSelectContext = React.useMemo<TreeSelectContextProps>(
600-
() => ({
610+
const isOverMaxCount =
611+
mergedMultiple && maxCount !== undefined && cachedDisplayValues?.length >= maxCount;
612+
613+
const treeSelectContext = React.useMemo<TreeSelectContextProps>(() => {
614+
return {
601615
virtual,
602616
dropdownMatchSelectWidth,
603617
listHeight,
@@ -609,21 +623,25 @@ const TreeSelect = React.forwardRef<BaseSelectRef, TreeSelectProps>((props, ref)
609623
treeExpandAction,
610624
treeTitleRender,
611625
onPopupScroll,
612-
}),
613-
[
614-
virtual,
615-
dropdownMatchSelectWidth,
616-
listHeight,
617-
listItemHeight,
618-
listItemScrollOffset,
619-
filteredTreeData,
620-
mergedFieldNames,
621-
onOptionSelect,
622-
treeExpandAction,
623-
treeTitleRender,
624-
onPopupScroll,
625-
],
626-
);
626+
displayValues: cachedDisplayValues,
627+
isOverMaxCount,
628+
};
629+
}, [
630+
virtual,
631+
dropdownMatchSelectWidth,
632+
listHeight,
633+
listItemHeight,
634+
listItemScrollOffset,
635+
filteredTreeData,
636+
mergedFieldNames,
637+
onOptionSelect,
638+
treeExpandAction,
639+
treeTitleRender,
640+
onPopupScroll,
641+
maxCount,
642+
cachedDisplayValues,
643+
mergedMultiple,
644+
]);
627645

628646
// ======================= Legacy Context =======================
629647
const legacyContext = React.useMemo(

src/TreeSelectContext.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import type { ExpandAction } from 'rc-tree/lib/Tree';
3-
import type { DataNode, FieldNames, Key } from './interface';
3+
import type { DataNode, FieldNames, Key, LabeledValueType } from './interface';
44

55
export interface TreeSelectContextProps {
66
virtual?: boolean;
@@ -14,6 +14,8 @@ export interface TreeSelectContextProps {
1414
treeExpandAction?: ExpandAction;
1515
treeTitleRender?: (node: any) => React.ReactNode;
1616
onPopupScroll?: React.UIEventHandler<HTMLDivElement>;
17+
displayValues?: LabeledValueType[];
18+
isOverMaxCount?: boolean;
1719
}
1820

1921
const TreeSelectContext = React.createContext<TreeSelectContextProps>(null as any);

0 commit comments

Comments
 (0)