From fb8e6217e09bb1205045d4393e6c823c2297770a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sat, 9 Nov 2024 22:48:00 +0800 Subject: [PATCH 01/36] feat: TreeSelect support maxCount --- docs/demo/mutiple-with-maxCount.md | 8 ++++++ examples/mutiple-with-maxCount.tsx | 45 ++++++++++++++++++++++++++++++ src/OptionList.tsx | 19 ++++++++++--- src/TreeSelect.tsx | 8 ++++++ src/TreeSelectContext.ts | 1 + src/utils/valueUtil.ts | 3 ++ 6 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 docs/demo/mutiple-with-maxCount.md create mode 100644 examples/mutiple-with-maxCount.tsx diff --git a/docs/demo/mutiple-with-maxCount.md b/docs/demo/mutiple-with-maxCount.md new file mode 100644 index 00000000..2df96692 --- /dev/null +++ b/docs/demo/mutiple-with-maxCount.md @@ -0,0 +1,8 @@ +--- +title: mutiple-with-maxCount +nav: + title: Demo + path: /demo +--- + + diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx new file mode 100644 index 00000000..ff7ee4e2 --- /dev/null +++ b/examples/mutiple-with-maxCount.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import TreeSelect from '../src'; + +export default () => { + const treeData = [ + { + key: '1', + value: '1', + title: '1', + children: [ + { + key: '1-1', + value: '1-1', + title: '1-1', + }, + ], + }, + { + key: '2', + value: '2', + title: '2', + }, + { + key: '3', + value: '3', + title: '3', + }, + { + key: '4', + value: '4', + title: '4', + }, + ]; + const onChange = () => {}; + + return ( + + ); +}; diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 10e99760..209c6e81 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import LegacyContext from './LegacyContext'; import TreeSelectContext from './TreeSelectContext'; import type { Key, SafeKey } from './interface'; -import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; +import { getAllKeys, isCheckDisabled, isValidCount } from './utils/valueUtil'; const HIDDEN_STYLE = { width: 0, @@ -45,6 +45,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, treeExpandAction, treeTitleRender, onPopupScroll, + maxCount, } = React.useContext(TreeSelectContext); const { @@ -76,6 +77,11 @@ const OptionList: React.ForwardRefRenderFunction = (_, (prev, next) => next[0] && prev[1] !== next[1], ); + const isOverMaxCount = React.useMemo( + () => multiple && isValidCount(maxCount) && checkedKeys.length >= maxCount, + [checkedKeys, maxCount, multiple], + ); + // ========================== Active ========================== const [activeKey, setActiveKey] = React.useState(null); const activeEntity = keyEntities[activeKey as SafeKey]; @@ -150,8 +156,13 @@ const OptionList: React.ForwardRefRenderFunction = (_, return; } + const isSelected = !checkedKeys.includes(node.key); + if (maxCount && isSelected && checkedKeys.length >= maxCount) { + return; + } + onSelect(node.key, { - selected: !checkedKeys.includes(node.key), + selected: isSelected, }); if (!multiple) { @@ -197,10 +208,10 @@ const OptionList: React.ForwardRefRenderFunction = (_, })); const loadDataFun = useMemo( - () => searchValue ? null : (loadData as any), + () => (searchValue ? null : (loadData as any)), [searchValue, treeExpandedKeys || expandedKeys], ([preSearchValue], [nextSearchValue, nextExcludeSearchExpandedKeys]) => - preSearchValue !== nextSearchValue && !!(nextSearchValue || nextExcludeSearchExpandedKeys) + preSearchValue !== nextSearchValue && !!(nextSearchValue || nextExcludeSearchExpandedKeys), ); // ========================== Render ========================== diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index e8046954..aa035888 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -72,6 +72,7 @@ export interface TreeSelectProps>> Data treeData?: OptionType[]; @@ -136,6 +137,7 @@ const TreeSelect = React.forwardRef((props, ref) treeCheckable, treeCheckStrictly, labelInValue, + maxCount, // FieldNames fieldNames, @@ -507,6 +509,10 @@ const TreeSelect = React.forwardRef((props, ref) // Single mode always set value triggerChange([selectedValue], { selected: true, triggerValue: selectedValue }, 'option'); } else { + if (maxCount && selected && rawValues.length >= maxCount) { + return; + } + let newRawValues = selected ? [...rawValues, selectedValue] : rawCheckedValues.filter(v => v !== selectedValue); @@ -609,6 +615,7 @@ const TreeSelect = React.forwardRef((props, ref) treeExpandAction, treeTitleRender, onPopupScroll, + maxCount, }), [ virtual, @@ -622,6 +629,7 @@ const TreeSelect = React.forwardRef((props, ref) treeExpandAction, treeTitleRender, onPopupScroll, + maxCount, ], ); diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index b0aff525..0d07e9ac 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -14,6 +14,7 @@ export interface TreeSelectContextProps { treeExpandAction?: ExpandAction; treeTitleRender?: (node: any) => React.ReactNode; onPopupScroll?: React.UIEventHandler; + maxCount?: number; } const TreeSelectContext = React.createContext(null as any); diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index caa44727..4d281b80 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -35,3 +35,6 @@ export const getAllKeys = (treeData: DataNode[], fieldNames: FieldNames): SafeKe }; export const isNil = (val: any): boolean => val === null || val === undefined; + +export const isValidCount = (value?: number) => + typeof value !== 'undefined' && !Number.isNaN(value); From 95c7747e2efc445f256a5a6509c2babf60220f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sat, 9 Nov 2024 23:27:07 +0800 Subject: [PATCH 02/36] feat: sync activeKey state --- src/OptionList.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 209c6e81..4301de9d 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -86,6 +86,13 @@ const OptionList: React.ForwardRefRenderFunction = (_, const [activeKey, setActiveKey] = React.useState(null); const activeEntity = keyEntities[activeKey as SafeKey]; + const onActiveChange = (key: Key) => { + if (isOverMaxCount && !checkedKeys.includes(key)) { + return; + } + setActiveKey(key); + }; + // ========================== Values ========================== const mergedCheckedKeys = React.useMemo(() => { if (!checkable) { @@ -267,7 +274,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, titleRender={treeTitleRender} {...treeProps} // Proxy event out - onActiveChange={setActiveKey} + onActiveChange={onActiveChange} onSelect={onInternalSelect} onCheck={onInternalSelect} onExpand={onInternalExpand} From aa28c565a889f4dd3b293ddf9293074413b5227e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sat, 9 Nov 2024 23:44:03 +0800 Subject: [PATCH 03/36] feat: sync disabled state --- src/OptionList.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 4301de9d..f032fa2b 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -279,6 +279,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, onCheck={onInternalSelect} onExpand={onInternalExpand} onLoad={onTreeLoad} + disabled={isOverMaxCount} filterTreeNode={filterTreeNode} expandAction={treeExpandAction} onScroll={onPopupScroll} From 8d63a07b745ffcc74b8ac92cc09ae8cee40b8c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sun, 10 Nov 2024 13:33:38 +0800 Subject: [PATCH 04/36] feat: sync disabled state --- src/OptionList.tsx | 16 ++++++++++++++-- src/TreeSelect.tsx | 1 + 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index f032fa2b..43954edc 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -82,6 +82,19 @@ const OptionList: React.ForwardRefRenderFunction = (_, [checkedKeys, maxCount, multiple], ); + const traverse = (nodes: EventDataNode[]): EventDataNode[] => { + return nodes.map(node => ({ + ...node, + disabled: isOverMaxCount && !checkedKeys.includes(node.key as SafeKey) ? true : node.disabled, + children: node.children ? traverse(node.children) : undefined, + })); + }; + + const processedTreeData = React.useMemo( + () => traverse(memoTreeData), + [memoTreeData, isOverMaxCount, checkedKeys], + ); + // ========================== Active ========================== const [activeKey, setActiveKey] = React.useState(null); const activeEntity = keyEntities[activeKey as SafeKey]; @@ -252,7 +265,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, ref={treeRef} focusable={false} prefixCls={`${prefixCls}-tree`} - treeData={memoTreeData} + treeData={processedTreeData} height={listHeight} itemHeight={listItemHeight} itemScrollOffset={listItemScrollOffset} @@ -279,7 +292,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, onCheck={onInternalSelect} onExpand={onInternalExpand} onLoad={onTreeLoad} - disabled={isOverMaxCount} filterTreeNode={filterTreeNode} expandAction={treeExpandAction} onScroll={onPopupScroll} diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index aa035888..eca7096c 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -564,6 +564,7 @@ const TreeSelect = React.forwardRef((props, ref) onDeselect, rawCheckedValues, rawHalfCheckedValues, + maxCount, ], ); From baf5f263038450a26efeb75aecfcfcfe07766ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sun, 10 Nov 2024 13:43:33 +0800 Subject: [PATCH 05/36] docs: improve maxCount demo --- examples/mutiple-with-maxCount.tsx | 38 +++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index ff7ee4e2..a3a30f29 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import TreeSelect from '../src'; export default () => { + const [value, setValue] = useState(['1']); + const treeData = [ { key: '1', @@ -31,15 +33,33 @@ export default () => { title: '4', }, ]; - const onChange = () => {}; + + const onChange = (value: string[]) => { + setValue(value); + }; return ( - + <> +

multiple with maxCount

+ + +

checkable with maxCount

+ + ); }; From 828b5ee3c21e0cc29a0bdd31952f4af554f7a4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sun, 10 Nov 2024 14:51:42 +0800 Subject: [PATCH 06/36] test: add maxCount test cases --- tests/Select.multiple.spec.js | 120 ++++++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 7 deletions(-) diff --git a/tests/Select.multiple.spec.js b/tests/Select.multiple.spec.js index eba8a620..a91b95a3 100644 --- a/tests/Select.multiple.spec.js +++ b/tests/Select.multiple.spec.js @@ -1,5 +1,5 @@ /* eslint-disable no-undef */ -import { render } from '@testing-library/react'; +import { render, fireEvent, within } from '@testing-library/react'; import { mount } from 'enzyme'; import KeyCode from 'rc-util/lib/KeyCode'; import React from 'react'; @@ -32,7 +32,10 @@ describe('TreeSelect.multiple', () => { it('remove by backspace key', () => { const wrapper = mount(createSelect({ defaultValue: ['0', '1'] })); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); expect(wrapper.getSelection()).toHaveLength(1); expect(wrapper.getSelection(0).text()).toBe('label0'); }); @@ -59,9 +62,15 @@ describe('TreeSelect.multiple', () => { } } const wrapper = mount(); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); wrapper.selectNode(1); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); expect(wrapper.getSelection()).toHaveLength(1); expect(wrapper.getSelection(0).text()).toBe('label0'); }); @@ -337,9 +346,7 @@ describe('TreeSelect.multiple', () => { />, ); - const values = Array.from( - container.querySelectorAll('.rc-tree-select-selection-item-content'), - ); //.map(ele => ele.textContent); + const values = Array.from(container.querySelectorAll('.rc-tree-select-selection-item-content')); //.map(ele => ele.textContent); expect(values).toHaveLength(0); @@ -348,4 +355,103 @@ describe('TreeSelect.multiple', () => { expect(placeholder.textContent).toBe('Fake placeholder'); }); + describe('TreeSelect.maxCount', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label' }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + { key: '3', value: '3', title: '3 label' }, + ]; + + const renderTreeSelect = props => { + return render(); + }; + + const selectOptions = (container, optionTexts) => { + const dropdownList = container.querySelector('.rc-tree-select-dropdown'); + optionTexts.forEach(text => { + fireEvent.click(within(dropdownList).getByText(text)); + }); + }; + + it('should disable unselected options when selection reaches maxCount', () => { + const { container } = renderTreeSelect(); + + selectOptions(container, ['0 label', '1 label']); + + // Check if third and fourth options are disabled + const dropdownList = container.querySelector('.rc-tree-select-dropdown'); + const option3 = within(dropdownList).getByText('2 label'); + const option4 = within(dropdownList).getByText('3 label'); + + expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + expect(option4.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should allow deselecting options after reaching maxCount', () => { + const { container } = renderTreeSelect(); + const dropdownList = container.querySelector('.rc-tree-select-dropdown'); + + selectOptions(container, ['0 label', '1 label']); + + // Try selecting third option, should be disabled + const option3 = within(dropdownList).getByText('2 label'); + fireEvent.click(option3); + expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + + // Deselect first option + fireEvent.click(within(dropdownList).getByText('0 label')); + expect(within(dropdownList).queryByText('0 label')).toBeInTheDocument(); + + // Now should be able to select third option + fireEvent.click(option3); + expect(option3.closest('div')).not.toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should not trigger onChange when trying to select beyond maxCount', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try selecting third option + const dropdownList = container.querySelector('.rc-tree-select-dropdown'); + fireEvent.click(within(dropdownList).getByText('2 label')); + expect(handleChange).toHaveBeenCalledTimes(2); // Should not increase + }); + + it('should not affect deselection operations when maxCount is reached', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Deselect first option + const dropdownList = container.querySelector('.rc-tree-select-dropdown'); + fireEvent.click(within(dropdownList).getByText('0 label')); + expect(handleChange).toHaveBeenCalledTimes(3); + + // Should be able to select third option + fireEvent.click(within(dropdownList).getByText('2 label')); + expect(handleChange).toHaveBeenCalledTimes(4); + }); + + it('should not allow any selection when maxCount is 0', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ maxCount: 0, onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('should not limit selection when maxCount is greater than number of options', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ maxCount: 5, onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label', '2 label', '3 label']); + expect(handleChange).toHaveBeenCalledTimes(4); + }); + }); }); From ca8d966c911732184db988fc45207ef6c87c8687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 11 Nov 2024 11:14:28 +0800 Subject: [PATCH 07/36] chore: remove deadCode --- src/OptionList.tsx | 3 --- src/TreeSelect.tsx | 4 ---- 2 files changed, 7 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 43954edc..5adddca4 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -177,9 +177,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, } const isSelected = !checkedKeys.includes(node.key); - if (maxCount && isSelected && checkedKeys.length >= maxCount) { - return; - } onSelect(node.key, { selected: isSelected, diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index eca7096c..450c2738 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -509,10 +509,6 @@ const TreeSelect = React.forwardRef((props, ref) // Single mode always set value triggerChange([selectedValue], { selected: true, triggerValue: selectedValue }, 'option'); } else { - if (maxCount && selected && rawValues.length >= maxCount) { - return; - } - let newRawValues = selected ? [...rawValues, selectedValue] : rawCheckedValues.filter(v => v !== selectedValue); From cc3b8395c89b92b97405a8f7eba07c48dc6d1787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 11 Nov 2024 19:38:06 +0800 Subject: [PATCH 08/36] test: add test cases for keyboard operations --- examples/mutiple-with-maxCount.tsx | 15 ++- tests/Select.maxCount.spec.tsx | 157 +++++++++++++++++++++++++++++ tests/Select.multiple.spec.js | 100 ------------------ tests/util.tsx | 12 ++- 4 files changed, 181 insertions(+), 103 deletions(-) create mode 100644 tests/Select.maxCount.spec.tsx diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index 7c2283f9..41171c04 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -46,8 +46,8 @@ export default () => { multiple maxCount={3} treeData={treeData} - onChange={onChange} - value={value} + // onChange={onChange} + // value={value} />

checkable with maxCount

@@ -55,11 +55,22 @@ export default () => { style={{ width: 300 }} multiple treeCheckable + treeCheckStrictly maxCount={3} treeData={treeData} onChange={onChange} value={value} /> + ); }; diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx new file mode 100644 index 00000000..0e8f142e --- /dev/null +++ b/tests/Select.maxCount.spec.tsx @@ -0,0 +1,157 @@ +import { render, fireEvent, within } from '@testing-library/react'; +import KeyCode from 'rc-util/lib/KeyCode'; +import { keyDown, keyUp } from './util'; +import React from 'react'; +import TreeSelect from '../src'; + +describe('TreeSelect.maxCount', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label' }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + { key: '3', value: '3', title: '3 label' }, + ]; + + const renderTreeSelect = (props?: any) => { + return render(); + }; + + const selectOptions = (container, optionTexts) => { + const dropdownList = container.querySelector('.rc-tree-select-dropdown'); + optionTexts.forEach(text => { + fireEvent.click(within(dropdownList).getByText(text)); + }); + }; + + it('should disable unselected options when selection reaches maxCount', () => { + const { container } = renderTreeSelect(); + + selectOptions(container, ['0 label', '1 label']); + + // Check if third and fourth options are disabled + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + const option3 = within(dropdownList).getByText('2 label'); + const option4 = within(dropdownList).getByText('3 label'); + + expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + expect(option4.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should allow deselecting options after reaching maxCount', () => { + const { container } = renderTreeSelect(); + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + + selectOptions(container, ['0 label', '1 label']); + + // Try selecting third option, should be disabled + const option3 = within(dropdownList).getByText('2 label'); + fireEvent.click(option3); + expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + + // Deselect first option + fireEvent.click(within(dropdownList).getByText('0 label')); + expect(within(dropdownList).queryByText('0 label')).toBeInTheDocument(); + + // Now should be able to select third option + fireEvent.click(option3); + expect(option3.closest('div')).not.toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should not trigger onChange when trying to select beyond maxCount', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try selecting third option + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + fireEvent.click(within(dropdownList).getByText('2 label')); + expect(handleChange).toHaveBeenCalledTimes(2); // Should not increase + }); + + it('should not affect deselection operations when maxCount is reached', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Deselect first option + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + fireEvent.click(within(dropdownList).getByText('0 label')); + expect(handleChange).toHaveBeenCalledTimes(3); + + // Should be able to select third option + fireEvent.click(within(dropdownList).getByText('2 label')); + expect(handleChange).toHaveBeenCalledTimes(4); + }); + + it('should not allow any selection when maxCount is 0', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ maxCount: 0, onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('should not limit selection when maxCount is greater than number of options', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ maxCount: 5, onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label', '2 label', '3 label']); + expect(handleChange).toHaveBeenCalledTimes(4); + }); +}); + +describe('TreeSelect.maxCount keyboard operations', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label' }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + ]; + + it('keyboard operations should not exceed maxCount limit', () => { + const onSelect = jest.fn(); + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + expect(onSelect).toHaveBeenCalledWith('0', expect.anything()); + + keyDown(input, KeyCode.DOWN); + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); + + keyDown(input, KeyCode.DOWN); + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + expect(onSelect).toHaveBeenCalledTimes(2); + }); + + it('when maxCount is reached, the option should be disabled', () => { + const { container } = render( + , + ); + + // verify that the third option is disabled + expect(container.querySelector('.rc-tree-select-tree-treenode-disabled')?.textContent).toBe( + '2 label', + ); + }); +}); diff --git a/tests/Select.multiple.spec.js b/tests/Select.multiple.spec.js index a91b95a3..5f2cebca 100644 --- a/tests/Select.multiple.spec.js +++ b/tests/Select.multiple.spec.js @@ -354,104 +354,4 @@ describe('TreeSelect.multiple', () => { expect(placeholder).toBeTruthy(); expect(placeholder.textContent).toBe('Fake placeholder'); }); - - describe('TreeSelect.maxCount', () => { - const treeData = [ - { key: '0', value: '0', title: '0 label' }, - { key: '1', value: '1', title: '1 label' }, - { key: '2', value: '2', title: '2 label' }, - { key: '3', value: '3', title: '3 label' }, - ]; - - const renderTreeSelect = props => { - return render(); - }; - - const selectOptions = (container, optionTexts) => { - const dropdownList = container.querySelector('.rc-tree-select-dropdown'); - optionTexts.forEach(text => { - fireEvent.click(within(dropdownList).getByText(text)); - }); - }; - - it('should disable unselected options when selection reaches maxCount', () => { - const { container } = renderTreeSelect(); - - selectOptions(container, ['0 label', '1 label']); - - // Check if third and fourth options are disabled - const dropdownList = container.querySelector('.rc-tree-select-dropdown'); - const option3 = within(dropdownList).getByText('2 label'); - const option4 = within(dropdownList).getByText('3 label'); - - expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); - expect(option4.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); - }); - - it('should allow deselecting options after reaching maxCount', () => { - const { container } = renderTreeSelect(); - const dropdownList = container.querySelector('.rc-tree-select-dropdown'); - - selectOptions(container, ['0 label', '1 label']); - - // Try selecting third option, should be disabled - const option3 = within(dropdownList).getByText('2 label'); - fireEvent.click(option3); - expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); - - // Deselect first option - fireEvent.click(within(dropdownList).getByText('0 label')); - expect(within(dropdownList).queryByText('0 label')).toBeInTheDocument(); - - // Now should be able to select third option - fireEvent.click(option3); - expect(option3.closest('div')).not.toHaveClass('rc-tree-select-tree-treenode-disabled'); - }); - - it('should not trigger onChange when trying to select beyond maxCount', () => { - const handleChange = jest.fn(); - const { container } = renderTreeSelect({ onChange: handleChange }); - - selectOptions(container, ['0 label', '1 label']); - expect(handleChange).toHaveBeenCalledTimes(2); - - // Try selecting third option - const dropdownList = container.querySelector('.rc-tree-select-dropdown'); - fireEvent.click(within(dropdownList).getByText('2 label')); - expect(handleChange).toHaveBeenCalledTimes(2); // Should not increase - }); - - it('should not affect deselection operations when maxCount is reached', () => { - const handleChange = jest.fn(); - const { container } = renderTreeSelect({ onChange: handleChange }); - - selectOptions(container, ['0 label', '1 label']); - expect(handleChange).toHaveBeenCalledTimes(2); - - // Deselect first option - const dropdownList = container.querySelector('.rc-tree-select-dropdown'); - fireEvent.click(within(dropdownList).getByText('0 label')); - expect(handleChange).toHaveBeenCalledTimes(3); - - // Should be able to select third option - fireEvent.click(within(dropdownList).getByText('2 label')); - expect(handleChange).toHaveBeenCalledTimes(4); - }); - - it('should not allow any selection when maxCount is 0', () => { - const handleChange = jest.fn(); - const { container } = renderTreeSelect({ maxCount: 0, onChange: handleChange }); - - selectOptions(container, ['0 label', '1 label']); - expect(handleChange).not.toHaveBeenCalled(); - }); - - it('should not limit selection when maxCount is greater than number of options', () => { - const handleChange = jest.fn(); - const { container } = renderTreeSelect({ maxCount: 5, onChange: handleChange }); - - selectOptions(container, ['0 label', '1 label', '2 label', '3 label']); - expect(handleChange).toHaveBeenCalledTimes(4); - }); - }); }); diff --git a/tests/util.tsx b/tests/util.tsx index d9f7123d..0f25ca97 100644 --- a/tests/util.tsx +++ b/tests/util.tsx @@ -1,6 +1,16 @@ -import { fireEvent } from '@testing-library/react'; +import { fireEvent, createEvent } from '@testing-library/react'; export function selectNode(index = 0) { const treeNode = document.querySelectorAll('.rc-tree-select-tree-node-content-wrapper')[index]; fireEvent.click(treeNode); } + +export function keyDown(element: HTMLElement, keyCode: number) { + const event = createEvent.keyDown(element, { keyCode }); + fireEvent(element, event); +} + +export function keyUp(element: HTMLElement, keyCode: number) { + const event = createEvent.keyUp(element, { keyCode }); + fireEvent(element, event); +} From 50392137fcdab2df40e28e6f32af2d7a4fa9fa96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 11 Nov 2024 20:04:50 +0800 Subject: [PATCH 09/36] chore: remove useless code --- examples/mutiple-with-maxCount.tsx | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index 41171c04..4ee7618a 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -41,14 +41,7 @@ export default () => { return ( <>

multiple with maxCount

- +

checkable with maxCount

{ onChange={onChange} value={value} /> - ); }; From bd6b5ff9a1142314ad72b8c2152eeffc5d2fea53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 11 Nov 2024 22:31:01 +0800 Subject: [PATCH 10/36] test: add test case --- tests/Select.maxCount.spec.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx index 0e8f142e..785b29d3 100644 --- a/tests/Select.maxCount.spec.tsx +++ b/tests/Select.maxCount.spec.tsx @@ -154,4 +154,19 @@ describe('TreeSelect.maxCount keyboard operations', () => { '2 label', ); }); + + it('should be able to unselect after reaching maxCount', () => { + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + // cancel first selection + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + // verify only two options are selected + expect(container.querySelectorAll('.rc-tree-select-tree-treenode-selected')).toHaveLength(2); + }); }); From 15472c5c8e2650e793e5f4a0556edff73affa9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 11 Nov 2024 22:33:55 +0800 Subject: [PATCH 11/36] test: improve test case --- tests/Select.SearchInput.spec.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index 77fced28..cd2d15c3 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -221,14 +221,22 @@ describe('TreeSelect.SearchInput', () => { const input = getByRole('combobox'); fireEvent.change(input, { target: { value: '1' } }); fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); onSelect.mockReset(); // Search disabled node and press enter, should not select fireEvent.change(input, { target: { value: '2' } }); fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); expect(onSelect).not.toHaveBeenCalled(); onSelect.mockReset(); + + // Search and press enter, should select first matched non-disabled node + fireEvent.change(input, { target: { value: '3' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); + expect(onSelect).toHaveBeenCalledWith('3', expect.anything()); }); it('should not select node when no matches found', () => { From 043527fb209a4e86e8a6806907bd3d73062ec51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 11 Nov 2024 23:17:03 +0800 Subject: [PATCH 12/36] docs: add maxCount description --- README.md | 1 + src/OptionList.tsx | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c4d8a3c2..38a8010b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ online example: https://tree-select-react-component.vercel.app/ | allowClear | whether allowClear | bool | false | | maxTagTextLength | max tag text length to show | number | - | | maxTagCount | max tag count to show | number | - | +| maxCount | Limit the maximum number of items that can be selected in multiple mode | number | - | | maxTagPlaceholder | placeholder for omitted values | ReactNode/function(omittedValues) | - | | multiple | whether multiple select (true when enable treeCheckable) | bool | false | | disabled | whether disabled select | bool | false | diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 7564a73a..b7c8a53a 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -128,10 +128,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, return; } - const isSelected = !checkedKeys.includes(node.key); - onSelect(node.key, { - selected: isSelected, + selected: !checkedKeys.includes(node.key), }); if (!multiple) { From 182add5a195441fc64a9372be97f2f1a9b9ebadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 12 Nov 2024 00:22:55 +0800 Subject: [PATCH 13/36] feat: forbid check when checkedKeys more than maxCount --- src/TreeSelect.tsx | 9 +++++++++ tests/Select.maxCount.spec.tsx | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index c9df2efe..af46a8ab 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -415,6 +415,15 @@ const TreeSelect = React.forwardRef((props, ref) extra: { triggerValue?: SafeKey; selected?: boolean }, source: SelectSource, ) => { + // if treeCheckable and not treeCheckStrictly, check if exceed maxCount + if (treeCheckable && !treeCheckStrictly && maxCount !== undefined) { + const { checkedKeys } = conductCheck(newRawValues, true, keyEntities); + + if (checkedKeys.length > maxCount) { + return; + } + } + const labeledValues = convert2LabelValues(newRawValues); setInternalValue(labeledValues); diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx index 785b29d3..de51926d 100644 --- a/tests/Select.maxCount.spec.tsx +++ b/tests/Select.maxCount.spec.tsx @@ -102,6 +102,43 @@ describe('TreeSelect.maxCount', () => { selectOptions(container, ['0 label', '1 label', '2 label', '3 label']); expect(handleChange).toHaveBeenCalledTimes(4); }); + + it('should respect maxCount when checking parent node in treeCheckable mode', () => { + const treeData = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + { key: '0-2', value: '0-2', title: 'child 3' }, + ], + }, + ]; + + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Try to check parent node which would select all children + const checkbox = container.querySelector('.rc-tree-select-tree-checkbox'); + fireEvent.click(checkbox); + + // onChange should not be called since it would exceed maxCount + expect(handleChange).not.toHaveBeenCalled(); + + // Parent node should still be unchecked + expect(checkbox).not.toHaveClass('rc-tree-select-tree-checkbox-checked'); + }); }); describe('TreeSelect.maxCount keyboard operations', () => { From ac9010ccb5226e5e59f7596b194422f5f9641484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 12 Nov 2024 00:26:30 +0800 Subject: [PATCH 14/36] chore: demo improvement --- examples/mutiple-with-maxCount.tsx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index 4ee7618a..af0076a9 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -3,6 +3,7 @@ import TreeSelect from '../src'; export default () => { const [value, setValue] = useState(['1']); + const [checkValue, setCheckValue] = useState(['1']); const treeData = [ { @@ -15,6 +16,11 @@ export default () => { value: '1-1', title: '1-1', }, + { + key: '1-2', + value: '1-2', + title: '1-2', + }, ], }, { @@ -38,6 +44,10 @@ export default () => { setValue(val); }; + const onCheckChange = (val: string[]) => { + setCheckValue(val); + }; + return ( <>

multiple with maxCount

@@ -48,12 +58,23 @@ export default () => { style={{ width: 300 }} multiple treeCheckable - treeCheckStrictly maxCount={3} treeData={treeData} onChange={onChange} value={value} /> + +

checkable with maxCount and treeCheckStrictly

+ ); }; From c77d58503fb9d5d9fae8c05b36902af717e1932f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 13 Nov 2024 21:51:21 +0800 Subject: [PATCH 15/36] feat: adjust maxCount implement logic --- examples/mutiple-with-maxCount.tsx | 10 +++++- src/OptionList.tsx | 58 ++++++++++++++++-------------- src/TreeSelect.tsx | 15 +++++--- src/TreeSelectContext.ts | 3 +- src/utils/valueUtil.ts | 3 -- 5 files changed, 53 insertions(+), 36 deletions(-) diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index af0076a9..4cada955 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -21,6 +21,11 @@ export default () => { value: '1-2', title: '1-2', }, + { + key: '1-3', + value: '1-3', + title: '1-3', + }, ], }, { @@ -58,7 +63,10 @@ export default () => { style={{ width: 300 }} multiple treeCheckable - maxCount={3} + // showCheckedStrategy="SHOW_ALL" + showCheckedStrategy="SHOW_PARENT" + // showCheckedStrategy="SHOW_CHILD" + maxCount={4} treeData={treeData} onChange={onChange} value={value} diff --git a/src/OptionList.tsx b/src/OptionList.tsx index b7c8a53a..3fc7b94f 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import LegacyContext from './LegacyContext'; import TreeSelectContext from './TreeSelectContext'; import type { Key, SafeKey } from './interface'; -import { getAllKeys, isCheckDisabled, isValidCount } from './utils/valueUtil'; +import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; const HIDDEN_STYLE = { width: 0, @@ -46,6 +46,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, treeTitleRender, onPopupScroll, maxCount, + displayValues, } = React.useContext(TreeSelectContext); const { @@ -77,24 +78,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, (prev, next) => next[0] && prev[1] !== next[1], ); - const isOverMaxCount = React.useMemo( - () => multiple && isValidCount(maxCount) && checkedKeys.length >= maxCount, - [checkedKeys, maxCount, multiple], - ); - - const traverse = (nodes: EventDataNode[]): EventDataNode[] => { - return nodes.map(node => ({ - ...node, - disabled: isOverMaxCount && !checkedKeys.includes(node.key as SafeKey) ? true : node.disabled, - children: node.children ? traverse(node.children) : undefined, - })); - }; - - const processedTreeData = React.useMemo( - () => traverse(memoTreeData), - [memoTreeData, isOverMaxCount, checkedKeys], - ); - // ========================== Values ========================== const mergedCheckedKeys = React.useMemo(() => { if (!checkable) { @@ -202,13 +185,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, const [activeKey, setActiveKey] = React.useState(null); const activeEntity = keyEntities[activeKey as SafeKey]; - const onActiveChange = (key: Key) => { - if (isOverMaxCount && !checkedKeys.includes(key)) { - return; - } - setActiveKey(key); - }; - React.useEffect(() => { if (!open) { return; @@ -274,8 +250,36 @@ const OptionList: React.ForwardRefRenderFunction = (_, preSearchValue !== nextSearchValue && !!(nextSearchValue || nextExcludeSearchExpandedKeys), ); + const onActiveChange = (key: Key) => { + if (isOverMaxCount && !displayValues?.some(v => v.value === key)) { + return; + } + setActiveKey(key); + }; + + const isOverMaxCount = React.useMemo( + () => multiple && maxCount !== undefined && displayValues?.length >= maxCount, + [multiple, maxCount, displayValues?.length], + ); + + const traverse = (nodes: EventDataNode[]): EventDataNode[] => { + return nodes.map(node => ({ + ...node, + disabled: + isOverMaxCount && !displayValues?.some(v => v.value === node[fieldNames.value]) + ? true + : node.disabled, + children: node.children ? traverse(node.children) : undefined, + })); + }; + + const processedTreeData = React.useMemo( + () => traverse(memoTreeData), + [memoTreeData, isOverMaxCount, displayValues, fieldNames], + ); + // ========================== Render ========================== - if (memoTreeData.length === 0) { + if (processedTreeData.length === 0) { return (
{notFoundContent} diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index af46a8ab..6a2ab514 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -415,11 +415,16 @@ const TreeSelect = React.forwardRef((props, ref) extra: { triggerValue?: SafeKey; selected?: boolean }, source: SelectSource, ) => { - // if treeCheckable and not treeCheckStrictly, check if exceed maxCount - if (treeCheckable && !treeCheckStrictly && maxCount !== undefined) { - const { checkedKeys } = conductCheck(newRawValues, true, keyEntities); + // if multiple and maxCount is set, check if exceed maxCount + if (mergedMultiple && maxCount !== undefined) { + const formattedKeyList = formatStrategyValues( + newRawValues, + mergedShowCheckedStrategy, + keyEntities, + mergedFieldNames, + ); - if (checkedKeys.length > maxCount) { + if (formattedKeyList.length > maxCount) { return; } } @@ -622,6 +627,7 @@ const TreeSelect = React.forwardRef((props, ref) treeTitleRender, onPopupScroll, maxCount, + displayValues: cachedDisplayValues, }), [ virtual, @@ -636,6 +642,7 @@ const TreeSelect = React.forwardRef((props, ref) treeTitleRender, onPopupScroll, maxCount, + cachedDisplayValues, ], ); diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index 0d07e9ac..8135b086 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import type { ExpandAction } from 'rc-tree/lib/Tree'; -import type { DataNode, FieldNames, Key } from './interface'; +import type { DataNode, FieldNames, Key, LabeledValueType } from './interface'; export interface TreeSelectContextProps { virtual?: boolean; @@ -15,6 +15,7 @@ export interface TreeSelectContextProps { treeTitleRender?: (node: any) => React.ReactNode; onPopupScroll?: React.UIEventHandler; maxCount?: number; + displayValues?: LabeledValueType[]; } const TreeSelectContext = React.createContext(null as any); diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index 4d281b80..caa44727 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -35,6 +35,3 @@ export const getAllKeys = (treeData: DataNode[], fieldNames: FieldNames): SafeKe }; export const isNil = (val: any): boolean => val === null || val === undefined; - -export const isValidCount = (value?: number) => - typeof value !== 'undefined' && !Number.isNaN(value); From 3559f12181b8dfa6a57c3a0be8f5bdf6a4009806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 13 Nov 2024 21:52:41 +0800 Subject: [PATCH 16/36] fix: lint fix --- src/OptionList.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 3fc7b94f..01aada45 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -250,6 +250,11 @@ const OptionList: React.ForwardRefRenderFunction = (_, preSearchValue !== nextSearchValue && !!(nextSearchValue || nextExcludeSearchExpandedKeys), ); + const isOverMaxCount = React.useMemo( + () => multiple && maxCount !== undefined && displayValues?.length >= maxCount, + [multiple, maxCount, displayValues?.length], + ); + const onActiveChange = (key: Key) => { if (isOverMaxCount && !displayValues?.some(v => v.value === key)) { return; @@ -257,11 +262,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, setActiveKey(key); }; - const isOverMaxCount = React.useMemo( - () => multiple && maxCount !== undefined && displayValues?.length >= maxCount, - [multiple, maxCount, displayValues?.length], - ); - const traverse = (nodes: EventDataNode[]): EventDataNode[] => { return nodes.map(node => ({ ...node, From c72c25fdcfc47c8d8bd5aaf3c292976752eb821a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 13 Nov 2024 23:15:45 +0800 Subject: [PATCH 17/36] test: add test cases for maxCount --- tests/Select.maxCount.spec.tsx | 167 +++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx index de51926d..e0d8fc49 100644 --- a/tests/Select.maxCount.spec.tsx +++ b/tests/Select.maxCount.spec.tsx @@ -207,3 +207,170 @@ describe('TreeSelect.maxCount keyboard operations', () => { expect(container.querySelectorAll('.rc-tree-select-tree-treenode-selected')).toHaveLength(2); }); }); + +describe('TreeSelect.maxCount with different strategies', () => { + const treeData = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + { key: '0-2', value: '0-2', title: 'child 3' }, + ], + }, + ]; + + it('should respect maxCount with SHOW_PARENT strategy', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent node - should work as it only shows as one option + const parentCheckbox = within(container).getByText('parent'); + fireEvent.click(parentCheckbox); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('should respect maxCount with SHOW_CHILD strategy', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent node - should not work as it would show three children + const parentCheckbox = within(container).getByText('parent'); + fireEvent.click(parentCheckbox); + expect(handleChange).not.toHaveBeenCalled(); + + // Select individual children - should work until maxCount + const childCheckboxes = within(container).getAllByText(/child/); + fireEvent.click(childCheckboxes[0]); // first child + fireEvent.click(childCheckboxes[1]); // second child + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try to select third child - should not work + fireEvent.click(childCheckboxes[2]); + expect(handleChange).toHaveBeenCalledTimes(2); + }); + + it('should respect maxCount with SHOW_ALL strategy', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent node - should not work as it would show both parent and children + const parentCheckbox = within(container).getByText('parent'); + fireEvent.click(parentCheckbox); + expect(handleChange).not.toHaveBeenCalled(); + + // Select individual children + const childCheckboxes = within(container).getAllByText(/child/); + fireEvent.click(childCheckboxes[0]); + fireEvent.click(childCheckboxes[1]); + expect(handleChange).toHaveBeenCalledTimes(2); + }); +}); + +describe('TreeSelect.maxCount with treeCheckStrictly', () => { + const treeData = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + ], + }, + ]; + + it('should count parent and children separately when treeCheckStrictly is true', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent and one child - should work as they are counted separately + const parentCheckbox = within(container).getByText('parent'); + const checkboxes = within(container).getAllByText(/child/); + fireEvent.click(parentCheckbox); + fireEvent.click(checkboxes[0]); // first child + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try to select second child - should not work as maxCount is reached + fireEvent.click(checkboxes[1]); + expect(handleChange).toHaveBeenCalledTimes(2); + }); + + it('should allow deselecting when maxCount is reached', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + const parentCheckbox = within(container).getByText('parent'); + const checkboxes = within(container).getAllByText(/child/); + + // Select parent and first child + fireEvent.click(parentCheckbox); + fireEvent.click(checkboxes[0]); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Deselect parent + fireEvent.click(parentCheckbox); + expect(handleChange).toHaveBeenCalledTimes(3); + + // Now should be able to select second child + fireEvent.click(checkboxes[1]); + expect(handleChange).toHaveBeenCalledTimes(4); + }); +}); From c1785399381c3bdf5e13929ebb6156b7378d9c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 14 Nov 2024 11:49:33 +0800 Subject: [PATCH 18/36] chore: hoist state to context --- src/OptionList.tsx | 9 ++------- src/TreeSelect.tsx | 43 +++++++++++++++++++++------------------- src/TreeSelectContext.ts | 2 +- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 01aada45..c2aa3af0 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -45,8 +45,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, treeExpandAction, treeTitleRender, onPopupScroll, - maxCount, displayValues, + isOverMaxCount, } = React.useContext(TreeSelectContext); const { @@ -250,11 +250,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, preSearchValue !== nextSearchValue && !!(nextSearchValue || nextExcludeSearchExpandedKeys), ); - const isOverMaxCount = React.useMemo( - () => multiple && maxCount !== undefined && displayValues?.length >= maxCount, - [multiple, maxCount, displayValues?.length], - ); - const onActiveChange = (key: Key) => { if (isOverMaxCount && !displayValues?.some(v => v.value === key)) { return; @@ -275,7 +270,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, const processedTreeData = React.useMemo( () => traverse(memoTreeData), - [memoTreeData, isOverMaxCount, displayValues, fieldNames], + [memoTreeData, isOverMaxCount], ); // ========================== Render ========================== diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 6a2ab514..a356d016 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -613,8 +613,11 @@ const TreeSelect = React.forwardRef((props, ref) }); // ========================== Context =========================== - const treeSelectContext = React.useMemo( - () => ({ + const treeSelectContext = React.useMemo(() => { + const isOverMaxCount = + mergedMultiple && maxCount !== undefined && cachedDisplayValues?.length >= maxCount; + + return { virtual, dropdownMatchSelectWidth, listHeight, @@ -626,25 +629,25 @@ const TreeSelect = React.forwardRef((props, ref) treeExpandAction, treeTitleRender, onPopupScroll, - maxCount, displayValues: cachedDisplayValues, - }), - [ - virtual, - dropdownMatchSelectWidth, - listHeight, - listItemHeight, - listItemScrollOffset, - filteredTreeData, - mergedFieldNames, - onOptionSelect, - treeExpandAction, - treeTitleRender, - onPopupScroll, - maxCount, - cachedDisplayValues, - ], - ); + isOverMaxCount, + }; + }, [ + virtual, + dropdownMatchSelectWidth, + listHeight, + listItemHeight, + listItemScrollOffset, + filteredTreeData, + mergedFieldNames, + onOptionSelect, + treeExpandAction, + treeTitleRender, + onPopupScroll, + maxCount, + cachedDisplayValues, + mergedMultiple, + ]); // ======================= Legacy Context ======================= const legacyContext = React.useMemo( diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index 8135b086..2d335e4b 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -14,8 +14,8 @@ export interface TreeSelectContextProps { treeExpandAction?: ExpandAction; treeTitleRender?: (node: any) => React.ReactNode; onPopupScroll?: React.UIEventHandler; - maxCount?: number; displayValues?: LabeledValueType[]; + isOverMaxCount?: boolean; } const TreeSelectContext = React.createContext(null as any); From e9b59f93a56faa490ebd821c13d8235772f276ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 14 Nov 2024 18:55:05 +0800 Subject: [PATCH 19/36] chore: hoist traverse operation to TreeSelect --- examples/mutiple-with-maxCount.tsx | 8 +++++++- src/OptionList.tsx | 20 ++------------------ src/TreeSelect.tsx | 26 +++++++++++++++++++++----- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index 4cada955..41a9b77e 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -56,7 +56,13 @@ export default () => { return ( <>

multiple with maxCount

- +

checkable with maxCount

= (_, setActiveKey(key); }; - const traverse = (nodes: EventDataNode[]): EventDataNode[] => { - return nodes.map(node => ({ - ...node, - disabled: - isOverMaxCount && !displayValues?.some(v => v.value === node[fieldNames.value]) - ? true - : node.disabled, - children: node.children ? traverse(node.children) : undefined, - })); - }; - - const processedTreeData = React.useMemo( - () => traverse(memoTreeData), - [memoTreeData, isOverMaxCount], - ); - // ========================== Render ========================== - if (processedTreeData.length === 0) { + if (memoTreeData.length === 0) { return (
{notFoundContent} @@ -304,7 +288,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, ref={treeRef} focusable={false} prefixCls={`${prefixCls}-tree`} - treeData={processedTreeData} + treeData={memoTreeData} height={listHeight} itemHeight={listItemHeight} itemScrollOffset={listItemScrollOffset} diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index a356d016..4419e23f 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -613,17 +613,33 @@ const TreeSelect = React.forwardRef((props, ref) }); // ========================== Context =========================== - const treeSelectContext = React.useMemo(() => { - const isOverMaxCount = - mergedMultiple && maxCount !== undefined && cachedDisplayValues?.length >= maxCount; + const isOverMaxCount = + mergedMultiple && maxCount !== undefined && cachedDisplayValues?.length >= maxCount; + + const traverse = (nodes: DataNode[]): DataNode[] => { + return nodes.map(node => ({ + ...node, + disabled: + isOverMaxCount && !cachedDisplayValues?.some(v => v.value === node.value) + ? true + : node.disabled, + children: node.children ? traverse(node.children) : undefined, + })); + }; + const processedTreeData = React.useMemo( + () => traverse(filteredTreeData), + [filteredTreeData, isOverMaxCount], + ); + + const treeSelectContext = React.useMemo(() => { return { virtual, dropdownMatchSelectWidth, listHeight, listItemHeight, listItemScrollOffset, - treeData: filteredTreeData, + treeData: processedTreeData, fieldNames: mergedFieldNames, onSelect: onOptionSelect, treeExpandAction, @@ -638,7 +654,7 @@ const TreeSelect = React.forwardRef((props, ref) listHeight, listItemHeight, listItemScrollOffset, - filteredTreeData, + processedTreeData, mergedFieldNames, onOptionSelect, treeExpandAction, From feb012d0c004d2eaf207d8eb39ab8fdf985c4e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 21 Nov 2024 00:36:18 +0800 Subject: [PATCH 20/36] feat: improve keyboard navigation when reach maxCount --- src/OptionList.tsx | 69 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 4ccb3526..958cd9ee 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -206,6 +206,53 @@ const OptionList: React.ForwardRefRenderFunction = (_, setActiveKey(nextActiveKey); }, [open, searchValue]); + const getNextMatchingNode = ( + nodes: EventDataNode[], + currentKey: Key | null, + direction: 'next' | 'prev' = 'next', + ): EventDataNode | null => { + const availableNodes: EventDataNode[] = []; + + const collectNodes = (nodeList: EventDataNode[]) => { + nodeList.forEach(node => { + if (!node.disabled && node.selectable !== false) { + // only collect selected nodes + if (displayValues?.some(v => v.value === node[fieldNames.value])) { + availableNodes.push(node); + } + } + if (node[fieldNames.children]) { + collectNodes(node[fieldNames.children]); + } + }); + }; + + collectNodes(nodes); + + if (availableNodes.length === 0) { + return null; + } + + // if no current selected node, return first available node + if (!currentKey) { + return availableNodes[0]; + } + + const currentIndex = availableNodes.findIndex(node => node[fieldNames.value] === currentKey); + + // if current node is not in available nodes list, return first node + if (currentIndex === -1) { + return availableNodes[0]; + } + + const nextIndex = + direction === 'next' + ? (currentIndex + 1) % availableNodes.length + : (currentIndex - 1 + availableNodes.length) % availableNodes.length; + + return availableNodes[nextIndex]; + }; + // ========================= Keyboard ========================= React.useImperativeHandle(ref, () => ({ scrollTo: treeRef.current?.scrollTo, @@ -217,7 +264,18 @@ const OptionList: React.ForwardRefRenderFunction = (_, case KeyCode.DOWN: case KeyCode.LEFT: case KeyCode.RIGHT: - treeRef.current?.onKeyDown(event as React.KeyboardEvent); + if (isOverMaxCount) { + event.preventDefault(); + const direction = which === KeyCode.UP || which === KeyCode.LEFT ? 'prev' : 'next'; + const nextNode = getNextMatchingNode(memoTreeData, activeKey, direction); + if (nextNode) { + setActiveKey(nextNode[fieldNames.value]); + // ensure scroll to visible area + treeRef.current?.scrollTo({ key: nextNode[fieldNames.value] }); + } + } else { + treeRef.current?.onKeyDown(event as React.KeyboardEvent); + } break; // >>> Select item @@ -251,10 +309,15 @@ const OptionList: React.ForwardRefRenderFunction = (_, ); const onActiveChange = (key: Key) => { - if (isOverMaxCount && !displayValues?.some(v => v.value === key)) { + if (!isOverMaxCount) { + setActiveKey(key); return; } - setActiveKey(key); + + const nextNode = getNextMatchingNode(memoTreeData, key); + if (nextNode) { + setActiveKey(nextNode[fieldNames.value]); + } }; // ========================== Render ========================== From 06ee3284e4658a3872854870c6c6a230e36aa514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 21 Nov 2024 00:43:48 +0800 Subject: [PATCH 21/36] feat: improve keyboard navigation when reach maxCount --- src/OptionList.tsx | 51 +++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 958cd9ee..d2157e00 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -181,31 +181,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, return null; }; - // ========================== Active ========================== - const [activeKey, setActiveKey] = React.useState(null); - const activeEntity = keyEntities[activeKey as SafeKey]; - - React.useEffect(() => { - if (!open) { - return; - } - let nextActiveKey = null; - - const getFirstNode = () => { - const firstNode = getFirstMatchingNode(memoTreeData); - return firstNode ? firstNode[fieldNames.value] : null; - }; - - // single mode active first checked node - if (!multiple && checkedKeys.length && !searchValue) { - nextActiveKey = checkedKeys[0]; - } else { - nextActiveKey = getFirstNode(); - } - - setActiveKey(nextActiveKey); - }, [open, searchValue]); - + // ========================== Get Next Matching Node ========================== const getNextMatchingNode = ( nodes: EventDataNode[], currentKey: Key | null, @@ -253,6 +229,31 @@ const OptionList: React.ForwardRefRenderFunction = (_, return availableNodes[nextIndex]; }; + // ========================== Active ========================== + const [activeKey, setActiveKey] = React.useState(null); + const activeEntity = keyEntities[activeKey as SafeKey]; + + React.useEffect(() => { + if (!open) { + return; + } + let nextActiveKey = null; + + const getFirstNode = () => { + const firstNode = getFirstMatchingNode(memoTreeData); + return firstNode ? firstNode[fieldNames.value] : null; + }; + + // single mode active first checked node + if (!multiple && checkedKeys.length && !searchValue) { + nextActiveKey = checkedKeys[0]; + } else { + nextActiveKey = getFirstNode(); + } + + setActiveKey(nextActiveKey); + }, [open, searchValue]); + // ========================= Keyboard ========================= React.useImperativeHandle(ref, () => ({ scrollTo: treeRef.current?.scrollTo, From ff9f2169bd3ff50163835a69ce67a5de91b896a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 21 Nov 2024 10:10:21 +0800 Subject: [PATCH 22/36] perf: use cache to improve navigation performance --- src/OptionList.tsx | 49 ++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index d2157e00..3d02df74 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -182,41 +182,24 @@ const OptionList: React.ForwardRefRenderFunction = (_, }; // ========================== Get Next Matching Node ========================== + const availableNodesRef = React.useRef[]>([]); + const getNextMatchingNode = ( - nodes: EventDataNode[], currentKey: Key | null, direction: 'next' | 'prev' = 'next', ): EventDataNode | null => { - const availableNodes: EventDataNode[] = []; - - const collectNodes = (nodeList: EventDataNode[]) => { - nodeList.forEach(node => { - if (!node.disabled && node.selectable !== false) { - // only collect selected nodes - if (displayValues?.some(v => v.value === node[fieldNames.value])) { - availableNodes.push(node); - } - } - if (node[fieldNames.children]) { - collectNodes(node[fieldNames.children]); - } - }); - }; - - collectNodes(nodes); + const availableNodes = availableNodesRef.current; if (availableNodes.length === 0) { return null; } - // if no current selected node, return first available node if (!currentKey) { return availableNodes[0]; } const currentIndex = availableNodes.findIndex(node => node[fieldNames.value] === currentKey); - // if current node is not in available nodes list, return first node if (currentIndex === -1) { return availableNodes[0]; } @@ -229,6 +212,27 @@ const OptionList: React.ForwardRefRenderFunction = (_, return availableNodes[nextIndex]; }; + React.useEffect(() => { + const nodes: EventDataNode[] = []; + const selectedValueSet = new Set(displayValues?.map(v => v.value)); + + const collectNodes = (nodeList: EventDataNode[]) => { + nodeList.forEach(node => { + if (!node.disabled && node.selectable !== false) { + if (selectedValueSet.has(node[fieldNames.value])) { + nodes.push(node); + } + } + if (node[fieldNames.children]) { + collectNodes(node[fieldNames.children]); + } + }); + }; + + collectNodes(memoTreeData); + availableNodesRef.current = nodes; + }, [displayValues, memoTreeData]); + // ========================== Active ========================== const [activeKey, setActiveKey] = React.useState(null); const activeEntity = keyEntities[activeKey as SafeKey]; @@ -268,10 +272,9 @@ const OptionList: React.ForwardRefRenderFunction = (_, if (isOverMaxCount) { event.preventDefault(); const direction = which === KeyCode.UP || which === KeyCode.LEFT ? 'prev' : 'next'; - const nextNode = getNextMatchingNode(memoTreeData, activeKey, direction); + const nextNode = getNextMatchingNode(activeKey, direction); if (nextNode) { setActiveKey(nextNode[fieldNames.value]); - // ensure scroll to visible area treeRef.current?.scrollTo({ key: nextNode[fieldNames.value] }); } } else { @@ -315,7 +318,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, return; } - const nextNode = getNextMatchingNode(memoTreeData, key); + const nextNode = getNextMatchingNode(key); if (nextNode) { setActiveKey(nextNode[fieldNames.value]); } From a5625a159d6a630138e565324c3683b8b1e961a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 21 Nov 2024 16:40:43 +0800 Subject: [PATCH 23/36] refactor: reuse formatStrategyValues --- src/OptionList.tsx | 2 +- src/TreeSelect.tsx | 20 +++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 3d02df74..3b492844 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -151,7 +151,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, React.useEffect(() => { if (searchValue) { - setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); + setSearchExpandedKeys(getAllKeys(memoTreeData, fieldNames)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 4419e23f..e5b75ea0 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -415,15 +415,15 @@ const TreeSelect = React.forwardRef((props, ref) extra: { triggerValue?: SafeKey; selected?: boolean }, source: SelectSource, ) => { + const formattedKeyList = formatStrategyValues( + newRawValues, + mergedShowCheckedStrategy, + keyEntities, + mergedFieldNames, + ); + // if multiple and maxCount is set, check if exceed maxCount if (mergedMultiple && maxCount !== undefined) { - const formattedKeyList = formatStrategyValues( - newRawValues, - mergedShowCheckedStrategy, - keyEntities, - mergedFieldNames, - ); - if (formattedKeyList.length > maxCount) { return; } @@ -441,12 +441,6 @@ const TreeSelect = React.forwardRef((props, ref) if (onChange) { let eventValues: SafeKey[] = newRawValues; if (treeConduction) { - const formattedKeyList = formatStrategyValues( - newRawValues, - mergedShowCheckedStrategy, - keyEntities, - mergedFieldNames, - ); eventValues = formattedKeyList.map(key => { const entity = valueEntities.get(key); return entity ? entity.node[mergedFieldNames.value] : key; From 856be0070a284620cb107dd2ccfde1205c2b35b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Fri, 22 Nov 2024 11:13:58 +0800 Subject: [PATCH 24/36] feat: add disabledStrategy --- src/OptionList.tsx | 18 +++++++++++++++++- src/TreeSelect.tsx | 20 ++------------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 3b492844..8b1c32d6 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -8,7 +8,7 @@ import useMemo from 'rc-util/lib/hooks/useMemo'; import * as React from 'react'; import LegacyContext from './LegacyContext'; import TreeSelectContext from './TreeSelectContext'; -import type { Key, SafeKey } from './interface'; +import type { DataNode, Key, SafeKey } from './interface'; import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; const HIDDEN_STYLE = { @@ -156,6 +156,21 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); + const disabledStrategy = (node: DataNode) => { + if (node.disabled) { + return true; + } + + if (isOverMaxCount) { + const selectedValues = displayValues?.map(v => v.value) || []; + if (!selectedValues.includes(node[fieldNames.value])) { + return true; + } + } + + return undefined; + }; + // ========================== Get First Selectable Node ========================== const getFirstMatchingNode = (nodes: EventDataNode[]): EventDataNode | null => { for (const node of nodes) { @@ -385,6 +400,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, filterTreeNode={filterTreeNode} expandAction={treeExpandAction} onScroll={onPopupScroll} + disabledStrategy={disabledStrategy} />
); diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index e5b75ea0..c46f49a8 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -610,22 +610,6 @@ const TreeSelect = React.forwardRef((props, ref) const isOverMaxCount = mergedMultiple && maxCount !== undefined && cachedDisplayValues?.length >= maxCount; - const traverse = (nodes: DataNode[]): DataNode[] => { - return nodes.map(node => ({ - ...node, - disabled: - isOverMaxCount && !cachedDisplayValues?.some(v => v.value === node.value) - ? true - : node.disabled, - children: node.children ? traverse(node.children) : undefined, - })); - }; - - const processedTreeData = React.useMemo( - () => traverse(filteredTreeData), - [filteredTreeData, isOverMaxCount], - ); - const treeSelectContext = React.useMemo(() => { return { virtual, @@ -633,7 +617,7 @@ const TreeSelect = React.forwardRef((props, ref) listHeight, listItemHeight, listItemScrollOffset, - treeData: processedTreeData, + treeData: filteredTreeData, fieldNames: mergedFieldNames, onSelect: onOptionSelect, treeExpandAction, @@ -648,7 +632,7 @@ const TreeSelect = React.forwardRef((props, ref) listHeight, listItemHeight, listItemScrollOffset, - processedTreeData, + filteredTreeData, mergedFieldNames, onOptionSelect, treeExpandAction, From e489e17d434a1f0a4e3b8a9e04c5bad67a8bbaf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Fri, 22 Nov 2024 15:26:14 +0800 Subject: [PATCH 25/36] feat: add code comment --- src/OptionList.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 8b1c32d6..258a9cc6 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -156,6 +156,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); + // >>> Disabled Strategy const disabledStrategy = (node: DataNode) => { if (node.disabled) { return true; From 06dee729533b513b3d04ecce584c0c42a95913cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sat, 23 Nov 2024 14:32:03 +0800 Subject: [PATCH 26/36] test: supplement test case for keyboard operation --- src/OptionList.tsx | 23 ++--------- tests/Select.maxCount.spec.tsx | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 258a9cc6..5d93fcbb 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -158,9 +158,9 @@ const OptionList: React.ForwardRefRenderFunction = (_, // >>> Disabled Strategy const disabledStrategy = (node: DataNode) => { - if (node.disabled) { - return true; - } + // if (node.disabled) { + // return true; + // } if (isOverMaxCount) { const selectedValues = displayValues?.map(v => v.value) || []; @@ -206,20 +206,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, ): EventDataNode | null => { const availableNodes = availableNodesRef.current; - if (availableNodes.length === 0) { - return null; - } - - if (!currentKey) { - return availableNodes[0]; - } - const currentIndex = availableNodes.findIndex(node => node[fieldNames.value] === currentKey); - if (currentIndex === -1) { - return availableNodes[0]; - } - const nextIndex = direction === 'next' ? (currentIndex + 1) % availableNodes.length @@ -333,11 +321,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, setActiveKey(key); return; } - - const nextNode = getNextMatchingNode(key); - if (nextNode) { - setActiveKey(nextNode[fieldNames.value]); - } }; // ========================== Render ========================== diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx index e0d8fc49..3c8202e5 100644 --- a/tests/Select.maxCount.spec.tsx +++ b/tests/Select.maxCount.spec.tsx @@ -206,6 +206,76 @@ describe('TreeSelect.maxCount keyboard operations', () => { // verify only two options are selected expect(container.querySelectorAll('.rc-tree-select-tree-treenode-selected')).toHaveLength(2); }); + + it('should cycle through selected options when maxCount is reached', () => { + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + keyDown(input, KeyCode.DOWN); + expect( + container.querySelector('.rc-tree-select-tree-treenode.rc-tree-select-tree-treenode-active') + ?.textContent, + ).toBe('2 label'); + + // Move down again to cycle back to the first selected item + keyDown(input, KeyCode.DOWN); + expect( + container.querySelector('.rc-tree-select-tree-treenode.rc-tree-select-tree-treenode-active') + ?.textContent, + ).toBe('0 label'); + }); + + it('should cycle through selected options in reverse when using UP key', () => { + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + // Initially activate the last selected item + keyDown(input, KeyCode.UP); + expect( + container.querySelector('.rc-tree-select-tree-treenode.rc-tree-select-tree-treenode-active') + ?.textContent, + ).toBe('2 label'); + + // Move up again to cycle back to the first selected item + keyDown(input, KeyCode.UP); + expect( + container.querySelector('.rc-tree-select-tree-treenode.rc-tree-select-tree-treenode-active') + ?.textContent, + ).toBe('0 label'); + + // Move up again to cycle back to the last selected item + keyDown(input, KeyCode.UP); + expect( + container.querySelector('.rc-tree-select-tree-treenode.rc-tree-select-tree-treenode-active') + ?.textContent, + ).toBe('2 label'); + }); + + it('should handle LEFT/RIGHT keys correctly when maxCount is reached', () => { + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + keyDown(input, KeyCode.RIGHT); + expect( + container.querySelector('.rc-tree-select-tree-treenode.rc-tree-select-tree-treenode-active') + ?.textContent, + ).toBe('2 label'); + + keyDown(input, KeyCode.LEFT); + expect( + container.querySelector('.rc-tree-select-tree-treenode.rc-tree-select-tree-treenode-active') + ?.textContent, + ).toBe('0 label'); + }); }); describe('TreeSelect.maxCount with different strategies', () => { From 667dc467dcdaef874242315166b7acd8d0c86667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sat, 23 Nov 2024 14:54:10 +0800 Subject: [PATCH 27/36] chore: handle git conflicts manually --- src/OptionList.tsx | 12 ++++----- tests/Select.loadData.spec.tsx | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 tests/Select.loadData.spec.tsx diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 5d93fcbb..9548a7a6 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -158,10 +158,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, // >>> Disabled Strategy const disabledStrategy = (node: DataNode) => { - // if (node.disabled) { - // return true; - // } - if (isOverMaxCount) { const selectedValues = displayValues?.map(v => v.value) || []; if (!selectedValues.includes(node[fieldNames.value])) { @@ -309,13 +305,15 @@ const OptionList: React.ForwardRefRenderFunction = (_, onKeyUp: () => {}, })); - const loadDataFun = useMemo( - () => (searchValue ? null : (loadData as any)), + const hasLoadDataFn = useMemo( + () => (searchValue ? false : true), [searchValue, treeExpandedKeys || expandedKeys], ([preSearchValue], [nextSearchValue, nextExcludeSearchExpandedKeys]) => preSearchValue !== nextSearchValue && !!(nextSearchValue || nextExcludeSearchExpandedKeys), ); + const syncLoadData = hasLoadDataFn ? loadData : null; + const onActiveChange = (key: Key) => { if (!isOverMaxCount) { setActiveKey(key); @@ -364,7 +362,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, showIcon={showTreeIcon} switcherIcon={switcherIcon} showLine={treeLine} - loadData={loadDataFun} + loadData={syncLoadData} motion={treeMotion} activeKey={activeKey} // We handle keys by out instead tree self diff --git a/tests/Select.loadData.spec.tsx b/tests/Select.loadData.spec.tsx new file mode 100644 index 00000000..c938d95c --- /dev/null +++ b/tests/Select.loadData.spec.tsx @@ -0,0 +1,46 @@ +/* eslint-disable no-undef, react/no-multi-comp, no-console */ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; + +import TreeSelect from '../src'; + +describe('TreeSelect.loadData', () => { + it('keep sync', async () => { + const Demo = () => { + const [treeData, setTreeData] = React.useState([ + { + title: '0', + value: 0, + isLeaf: false, + }, + ]); + + const loadData = async () => { + const nextId = treeData.length; + + setTreeData([ + ...treeData, + { + title: `${nextId}`, + value: nextId, + isLeaf: false, + }, + ]); + }; + + return ; + }; + + render(); + + for (let i = 0; i < 5; i += 1) { + fireEvent.click(document.querySelector('.rc-tree-select-tree-switcher_close')); + await act(async () => { + await Promise.resolve(); + }); + expect( + document.querySelectorAll('.rc-tree-select-tree-list .rc-tree-select-tree-treenode'), + ).toHaveLength(2 + i); + } + }); +}); From f8e5f61c678e5894309211106cc4a6ab121cd6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sat, 23 Nov 2024 15:48:01 +0800 Subject: [PATCH 28/36] chore: remove useless code --- src/OptionList.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 9548a7a6..7c9a401a 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -314,13 +314,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, const syncLoadData = hasLoadDataFn ? loadData : null; - const onActiveChange = (key: Key) => { - if (!isOverMaxCount) { - setActiveKey(key); - return; - } - }; - // ========================== Render ========================== if (memoTreeData.length === 0) { return ( @@ -374,7 +367,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, titleRender={treeTitleRender} {...treeProps} // Proxy event out - onActiveChange={onActiveChange} + onActiveChange={setActiveKey} onSelect={onInternalSelect} onCheck={onInternalSelect} onExpand={onInternalExpand} From 7df686af434b6534b22eec14feff6092766a6b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 25 Nov 2024 22:53:00 +0800 Subject: [PATCH 29/36] chore: memories displayValues --- src/OptionList.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 7c9a401a..f428e24f 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -78,6 +78,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, (prev, next) => next[0] && prev[1] !== next[1], ); + const memoDisplayValues = React.useMemo(() => displayValues?.map(v => v.value), [displayValues]); + // ========================== Values ========================== const mergedCheckedKeys = React.useMemo(() => { if (!checkable) { @@ -159,7 +161,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // >>> Disabled Strategy const disabledStrategy = (node: DataNode) => { if (isOverMaxCount) { - const selectedValues = displayValues?.map(v => v.value) || []; + const selectedValues = memoDisplayValues; if (!selectedValues.includes(node[fieldNames.value])) { return true; } @@ -214,7 +216,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, React.useEffect(() => { const nodes: EventDataNode[] = []; - const selectedValueSet = new Set(displayValues?.map(v => v.value)); + const selectedValueSet = new Set(memoDisplayValues); const collectNodes = (nodeList: EventDataNode[]) => { nodeList.forEach(node => { @@ -231,7 +233,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, collectNodes(memoTreeData); availableNodesRef.current = nodes; - }, [displayValues, memoTreeData]); + }, [memoDisplayValues, memoTreeData]); // ========================== Active ========================== const [activeKey, setActiveKey] = React.useState(null); From c69fcae2d0263cf744dcd25183449f0b596e804f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 28 Nov 2024 00:29:25 +0800 Subject: [PATCH 30/36] refactor: use InternalContext --- src/OptionList.tsx | 78 +++++++++--------- .../Select.checkable.spec.tsx.snap | 80 +++++++++++++++---- tests/__snapshots__/Select.spec.tsx.snap | 65 +++++++++------ 3 files changed, 144 insertions(+), 79 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index f428e24f..4b753432 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -2,6 +2,7 @@ import { useBaseProps } from 'rc-select'; import type { RefOptionListProps } from 'rc-select/lib/OptionList'; import type { TreeProps } from 'rc-tree'; import Tree from 'rc-tree'; +import { InternalContext } from 'rc-tree'; import type { EventDataNode, ScrollTo } from 'rc-tree/lib/interface'; import KeyCode from 'rc-util/lib/KeyCode'; import useMemo from 'rc-util/lib/hooks/useMemo'; @@ -158,8 +159,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); - // >>> Disabled Strategy - const disabledStrategy = (node: DataNode) => { + const nodeDisabled = (node: DataNode) => { if (isOverMaxCount) { const selectedValues = memoDisplayValues; if (!selectedValues.includes(node[fieldNames.value])) { @@ -342,43 +342,43 @@ const OptionList: React.ForwardRefRenderFunction = (_, {activeEntity.node.value} )} - - + + +
); }; diff --git a/tests/__snapshots__/Select.checkable.spec.tsx.snap b/tests/__snapshots__/Select.checkable.spec.tsx.snap index d13b495b..7ee92e82 100644 --- a/tests/__snapshots__/Select.checkable.spec.tsx.snap +++ b/tests/__snapshots__/Select.checkable.spec.tsx.snap @@ -142,7 +142,6 @@ exports[`TreeSelect.checkable uncheck remove by selector not treeCheckStrictly 1