From e2112e9ad2344d6d15646f618181d0b669fa073c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Sun, 3 Nov 2024 23:02:13 +0800 Subject: [PATCH 01/25] feat(a11y): enhance keyboard interaction in search mode --- src/OptionList.tsx | 32 ++++++++++- tests/Select.SearchInput.spec.js | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 10e99760..67593322 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -159,6 +159,21 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }; + const getFirstMatchNode = (nodes: EventDataNode[]): EventDataNode | null => { + for (const node of nodes) { + if (filterTreeNode(node) && !node.disabled) { + return node; + } + if (node[fieldNames.children]) { + const matchInChildren = getFirstMatchNode(node[fieldNames.children]); + if (matchInChildren) { + return matchInChildren; + } + } + } + return null; + }; + // ========================= Keyboard ========================= React.useImperativeHandle(ref, () => ({ scrollTo: treeRef.current?.scrollTo, @@ -175,7 +190,18 @@ const OptionList: React.ForwardRefRenderFunction = (_, // >>> Select item case KeyCode.ENTER: { - if (activeEntity) { + if (searchValue) { + const firstMatchNode = getFirstMatchNode(memoTreeData); + + if (firstMatchNode) { + if (firstMatchNode.selectable !== false) { + onInternalSelect(null, { + node: { key: firstMatchNode[fieldNames.value] }, + selected: !checkedKeys.includes(firstMatchNode[fieldNames.value]), + }); + } + } + } else if (activeEntity) { const { selectable, value } = activeEntity?.node || {}; if (selectable !== false) { onInternalSelect(null, { @@ -197,10 +223,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/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index e859c2a9..c2abd10b 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { mount } from 'enzyme'; import TreeSelect, { TreeNode } from '../src'; +import KeyCode from 'rc-util/lib/KeyCode'; describe('TreeSelect.SearchInput', () => { it('select item will clean searchInput', () => { @@ -198,4 +199,98 @@ describe('TreeSelect.SearchInput', () => { nodes.first().simulate('click'); expect(called).toBe(1); }); + + describe('keyboard events', () => { + it('should select first matched node when press enter', () => { + const onSelect = jest.fn(); + const wrapper = mount( + , + ); + + // Search and press enter, should select first matched non-disabled node + wrapper.search('1'); + wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); + + onSelect.mockReset(); + + // Search disabled node and press enter, should not select + wrapper.search('2'); + wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('should not select any node when no search value and press enter', () => { + const onSelect = jest.fn(); + const wrapper = mount( + , + ); + + // Press enter without search value, should not select any node + wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + expect(onSelect).not.toHaveBeenCalled(); + + // Search and press enter, should select first matched node + wrapper.search('1'); + wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); + }); + + it('should not select node when no matches found', () => { + const onSelect = jest.fn(); + const wrapper = mount( + , + ); + + // Search non-existent value and press enter, should not select any node + wrapper.search('not-exist'); + wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('should ignore enter press when all matched nodes are disabled', () => { + const onSelect = jest.fn(); + const wrapper = mount( + , + ); + + // When all matched nodes are disabled, press enter should not select any node + wrapper.search('1'); + wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + expect(onSelect).not.toHaveBeenCalled(); + }); + }); }); From 0411b7a697e43ac1b08a65e95417643e427e3d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 4 Nov 2024 20:33:09 +0800 Subject: [PATCH 02/25] refactor(a11y): synchronize activeKey with search state --- src/OptionList.tsx | 81 +++++++++---------- tests/Select.SearchInput.spec.js | 22 +++++ .../Select.checkable.spec.tsx.snap | 20 ++++- tests/__snapshots__/Select.spec.tsx.snap | 30 +++++-- 4 files changed, 101 insertions(+), 52 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 67593322..4769ed4d 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -102,42 +102,21 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - // ========================== Search ========================== - const lowerSearchValue = String(searchValue).toLowerCase(); - const filterTreeNode = (treeNode: EventDataNode) => { - if (!lowerSearchValue) { - return false; - } - return String(treeNode[treeNodeFilterProp]).toLowerCase().includes(lowerSearchValue); - }; - - // =========================== Keys =========================== - const [expandedKeys, setExpandedKeys] = React.useState(treeDefaultExpandedKeys); - const [searchExpandedKeys, setSearchExpandedKeys] = React.useState(null); - - const mergedExpandedKeys = React.useMemo(() => { - if (treeExpandedKeys) { - return [...treeExpandedKeys]; - } - return searchValue ? searchExpandedKeys : expandedKeys; - }, [expandedKeys, searchExpandedKeys, treeExpandedKeys, searchValue]); - + // =========================== Search Effect =========================== React.useEffect(() => { if (searchValue) { setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); + + const firstMatchNode = getFirstMatchNode(memoTreeData); + if (firstMatchNode) { + setActiveKey(firstMatchNode[fieldNames.value]); + } else { + setActiveKey(null); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); - const onInternalExpand = (keys: Key[]) => { - setExpandedKeys(keys); - setSearchExpandedKeys(keys); - - if (onTreeExpand) { - onTreeExpand(keys); - } - }; - // ========================== Events ========================== const onListMouseDown: React.MouseEventHandler = event => { event.preventDefault(); @@ -159,7 +138,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }; - const getFirstMatchNode = (nodes: EventDataNode[]): EventDataNode | null => { + const getFirstMatchNode = (nodes: EventDataNode): EventDataNode | null => { for (const node of nodes) { if (filterTreeNode(node) && !node.disabled) { return node; @@ -174,6 +153,35 @@ const OptionList: React.ForwardRefRenderFunction = (_, return null; }; + // ========================== Search ========================== + const lowerSearchValue = String(searchValue).toLowerCase(); + const filterTreeNode = (treeNode: EventDataNode) => { + if (!lowerSearchValue) { + return false; + } + return String(treeNode[treeNodeFilterProp]).toLowerCase().includes(lowerSearchValue); + }; + + // =========================== Keys =========================== + const [expandedKeys, setExpandedKeys] = React.useState(treeDefaultExpandedKeys); + const [searchExpandedKeys, setSearchExpandedKeys] = React.useState(null); + + const mergedExpandedKeys = React.useMemo(() => { + if (treeExpandedKeys) { + return [...treeExpandedKeys]; + } + return searchValue ? searchExpandedKeys : expandedKeys; + }, [expandedKeys, searchExpandedKeys, treeExpandedKeys, searchValue]); + + const onInternalExpand = (keys: Key[]) => { + setExpandedKeys(keys); + setSearchExpandedKeys(keys); + + if (onTreeExpand) { + onTreeExpand(keys); + } + }; + // ========================= Keyboard ========================= React.useImperativeHandle(ref, () => ({ scrollTo: treeRef.current?.scrollTo, @@ -190,18 +198,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // >>> Select item case KeyCode.ENTER: { - if (searchValue) { - const firstMatchNode = getFirstMatchNode(memoTreeData); - - if (firstMatchNode) { - if (firstMatchNode.selectable !== false) { - onInternalSelect(null, { - node: { key: firstMatchNode[fieldNames.value] }, - selected: !checkedKeys.includes(firstMatchNode[fieldNames.value]), - }); - } - } - } else if (activeEntity) { + if (activeEntity) { const { selectable, value } = activeEntity?.node || {}; if (selectable !== false) { onInternalSelect(null, { diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index c2abd10b..37473f1f 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -292,5 +292,27 @@ describe('TreeSelect.SearchInput', () => { wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); expect(onSelect).not.toHaveBeenCalled(); }); + + it('should activate first matched node when searching', () => { + const wrapper = mount( + , + ); + + // When searching, first matched non-disabled node should be activated + wrapper.search('1'); + expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('1'); + + // Should skip disabled nodes + wrapper.search('2'); + expect(wrapper.find('.rc-tree-select-tree-treenode-active').length).toBe(0); + }); }); }); diff --git a/tests/__snapshots__/Select.checkable.spec.tsx.snap b/tests/__snapshots__/Select.checkable.spec.tsx.snap index 1f498737..b7754d89 100644 --- a/tests/__snapshots__/Select.checkable.spec.tsx.snap +++ b/tests/__snapshots__/Select.checkable.spec.tsx.snap @@ -620,8 +620,14 @@ exports[`TreeSelect.checkable uncheck remove by tree check 1`] = ` >
+ + 0 +
@@ -660,7 +666,7 @@ exports[`TreeSelect.checkable uncheck remove by tree check 1`] = ` >
+ + 0 +
@@ -861,7 +873,7 @@ exports[`TreeSelect.checkable uncheck remove by tree check 2`] = ` >
+ + a +
@@ -780,7 +786,7 @@ exports[`TreeSelect.basic search nodes check tree changed by filter 1`] = ` >
+ + a +
@@ -896,7 +908,7 @@ exports[`TreeSelect.basic search nodes check tree changed by filter 2`] = ` >
+ + a +
@@ -1038,7 +1056,7 @@ exports[`TreeSelect.basic search nodes filter node but not remove then 1`] = ` >
Date: Tue, 5 Nov 2024 12:46:43 +0800 Subject: [PATCH 03/25] fix: lint fix --- src/OptionList.tsx | 60 +++++++++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 4769ed4d..75be9aa6 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -102,21 +102,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - // =========================== Search Effect =========================== - React.useEffect(() => { - if (searchValue) { - setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); - - const firstMatchNode = getFirstMatchNode(memoTreeData); - if (firstMatchNode) { - setActiveKey(firstMatchNode[fieldNames.value]); - } else { - setActiveKey(null); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchValue]); - // ========================== Events ========================== const onListMouseDown: React.MouseEventHandler = event => { event.preventDefault(); @@ -138,21 +123,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }; - const getFirstMatchNode = (nodes: EventDataNode): EventDataNode | null => { - for (const node of nodes) { - if (filterTreeNode(node) && !node.disabled) { - return node; - } - if (node[fieldNames.children]) { - const matchInChildren = getFirstMatchNode(node[fieldNames.children]); - if (matchInChildren) { - return matchInChildren; - } - } - } - return null; - }; - // ========================== Search ========================== const lowerSearchValue = String(searchValue).toLowerCase(); const filterTreeNode = (treeNode: EventDataNode) => { @@ -182,6 +152,36 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }; + const getFirstMatchNode = (nodes: EventDataNode): EventDataNode | null => { + for (const node of nodes) { + if (filterTreeNode(node) && !node.disabled) { + return node; + } + if (node[fieldNames.children]) { + const matchInChildren = getFirstMatchNode(node[fieldNames.children]); + if (matchInChildren) { + return matchInChildren; + } + } + } + return null; + }; + + // =========================== Search Effect =========================== + React.useEffect(() => { + if (searchValue) { + setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); + + const firstMatchNode = getFirstMatchNode(memoTreeData); + if (firstMatchNode) { + setActiveKey(firstMatchNode[fieldNames.value]); + } else { + setActiveKey(null); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchValue]); + // ========================= Keyboard ========================= React.useImperativeHandle(ref, () => ({ scrollTo: treeRef.current?.scrollTo, From a9efbc702cdd3ed692b7c5d8180bfedb47fde6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 14:47:34 +0800 Subject: [PATCH 04/25] feat: default active first item --- src/OptionList.tsx | 36 ++++++++++-- tests/Select.SearchInput.spec.js | 24 -------- tests/Select.spec.tsx | 58 ++++++++++++------- .../Select.checkable.spec.tsx.snap | 20 +++++-- tests/__snapshots__/Select.spec.tsx.snap | 40 ++++++++++--- 5 files changed, 116 insertions(+), 62 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 75be9aa6..7371e774 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -92,12 +92,36 @@ const OptionList: React.ForwardRefRenderFunction = (_, }; }, [checkable, checkedKeys, halfCheckedKeys]); + // ========================== Get First Selectable Node ========================== + const getFirstSelectableNode = (nodes: EventDataNode): EventDataNode | null => { + for (const node of nodes) { + if (node.selectable !== false) { + return node; + } + if (node[fieldNames.children]) { + const selectableInChildren = getFirstSelectableNode(node[fieldNames.children]); + if (selectableInChildren) { + return selectableInChildren; + } + } + } + return null; + }; + // ========================== Scroll ========================== React.useEffect(() => { - // Single mode should scroll to current key - if (open && !multiple && checkedKeys.length) { - treeRef.current?.scrollTo({ key: checkedKeys[0] }); - setActiveKey(checkedKeys[0]); + if (open) { + // Single mode should scroll to current key + if (!multiple && checkedKeys.length) { + treeRef.current?.scrollTo({ key: checkedKeys[0] }); + setActiveKey(checkedKeys[0]); + } else { + // Otherwise, activate the first selectable node + const firstSelectableNode = getFirstSelectableNode(memoTreeData); + if (firstSelectableNode) { + setActiveKey(firstSelectableNode[fieldNames.value]); + } + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); @@ -199,8 +223,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, // >>> Select item case KeyCode.ENTER: { if (activeEntity) { - const { selectable, value } = activeEntity?.node || {}; - if (selectable !== false) { + const { selectable, value, disabled } = activeEntity?.node || {}; + if (selectable !== false && !disabled) { onInternalSelect(null, { node: { key: activeKey }, selected: !checkedKeys.includes(value), diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index 37473f1f..49cec2cf 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -229,30 +229,6 @@ describe('TreeSelect.SearchInput', () => { expect(onSelect).not.toHaveBeenCalled(); }); - it('should not select any node when no search value and press enter', () => { - const onSelect = jest.fn(); - const wrapper = mount( - , - ); - - // Press enter without search value, should not select any node - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); - expect(onSelect).not.toHaveBeenCalled(); - - // Search and press enter, should select first matched node - wrapper.search('1'); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); - expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); - }); - it('should not select node when no matches found', () => { const onSelect = jest.fn(); const wrapper = mount( diff --git a/tests/Select.spec.tsx b/tests/Select.spec.tsx index a7f321e7..2de77b20 100644 --- a/tests/Select.spec.tsx +++ b/tests/Select.spec.tsx @@ -438,13 +438,13 @@ describe('TreeSelect.basic', () => { keyUp(KeyCode.DOWN); keyDown(KeyCode.ENTER); keyUp(KeyCode.ENTER); - matchValue(['parent']); + matchValue(['child']); keyDown(KeyCode.UP); keyUp(KeyCode.UP); keyDown(KeyCode.ENTER); keyUp(KeyCode.ENTER); - matchValue(['parent', 'child']); + matchValue(['child', 'parent']); }); it('selectable works with keyboard operations', () => { @@ -467,12 +467,12 @@ describe('TreeSelect.basic', () => { keyDown(KeyCode.DOWN); keyDown(KeyCode.ENTER); - expect(onChange).toHaveBeenCalledWith(['parent'], expect.anything(), expect.anything()); - onChange.mockReset(); + expect(onChange).not.toHaveBeenCalled(); keyDown(KeyCode.UP); keyDown(KeyCode.ENTER); - expect(onChange).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith(['parent'], expect.anything(), expect.anything()); + onChange.mockReset(); }); it('active index matches value', () => { @@ -535,6 +535,24 @@ describe('TreeSelect.basic', () => { keyDown(KeyCode.UP); expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('11 label'); }); + + it('should active first option when dropdown is opened', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label', disabled: true }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + ]; + + const wrapper = mount(); + + expect(wrapper.find('.rc-tree-select-tree-treenode-active')).toHaveLength(0); + + wrapper.openSelect(); + + const activeNode = wrapper.find('.rc-tree-select-tree-treenode-active'); + expect(activeNode).toHaveLength(1); + expect(activeNode.text()).toBe('0 label'); + }); }); it('click in list should preventDefault', () => { @@ -591,21 +609,21 @@ describe('TreeSelect.basic', () => { expect(container.querySelector('.rc-tree-select-selector').textContent).toBe('parent 1-0'); }); - it('should not add new tag when key enter is pressed if nothing is active', () => { - const onSelect = jest.fn(); - - const wrapper = mount( - - - - - - , - ); - - wrapper.find('input').first().simulate('keydown', { which: KeyCode.ENTER }); - expect(onSelect).not.toHaveBeenCalled(); - }); + // it('should not add new tag when key enter is pressed if nothing is active', () => { + // const onSelect = jest.fn(); + + // const wrapper = mount( + // + // + // + // + // + // , + // ); + + // wrapper.find('input').first().simulate('keydown', { which: KeyCode.ENTER }); + // expect(onSelect).not.toHaveBeenCalled(); + // }); it('should not select parent if some children is disabled', () => { const onChange = jest.fn(); diff --git a/tests/__snapshots__/Select.checkable.spec.tsx.snap b/tests/__snapshots__/Select.checkable.spec.tsx.snap index b7754d89..d13b495b 100644 --- a/tests/__snapshots__/Select.checkable.spec.tsx.snap +++ b/tests/__snapshots__/Select.checkable.spec.tsx.snap @@ -134,8 +134,14 @@ exports[`TreeSelect.checkable uncheck remove by selector not treeCheckStrictly 1 >
+ + 0 +
@@ -174,7 +180,7 @@ exports[`TreeSelect.checkable uncheck remove by selector not treeCheckStrictly 1 >
+ + 0 +
@@ -378,7 +390,7 @@ exports[`TreeSelect.checkable uncheck remove by selector not treeCheckStrictly 2 >
+ + 0 +
@@ -81,7 +87,7 @@ exports[`TreeSelect.basic render renders TreeNode correctly 1`] = ` >
+ + 0 +
@@ -283,7 +295,7 @@ exports[`TreeSelect.basic render renders TreeNode correctly with falsy child 1`] >
+ + 0 +
@@ -634,7 +652,7 @@ exports[`TreeSelect.basic render renders treeDataSimpleMode correctly 1`] = ` >
+ + a +
@@ -1196,7 +1220,7 @@ exports[`TreeSelect.basic search nodes renders search input 1`] = ` >
Date: Tue, 5 Nov 2024 14:53:56 +0800 Subject: [PATCH 05/25] chore: remove useless test case --- tests/Select.spec.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/Select.spec.tsx b/tests/Select.spec.tsx index 2de77b20..b0342eb5 100644 --- a/tests/Select.spec.tsx +++ b/tests/Select.spec.tsx @@ -609,22 +609,6 @@ describe('TreeSelect.basic', () => { expect(container.querySelector('.rc-tree-select-selector').textContent).toBe('parent 1-0'); }); - // it('should not add new tag when key enter is pressed if nothing is active', () => { - // const onSelect = jest.fn(); - - // const wrapper = mount( - // - // - // - // - // - // , - // ); - - // wrapper.find('input').first().simulate('keydown', { which: KeyCode.ENTER }); - // expect(onSelect).not.toHaveBeenCalled(); - // }); - it('should not select parent if some children is disabled', () => { const onChange = jest.fn(); From f4f76ab379071536897f2141d655dde6561e3620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 14:58:57 +0800 Subject: [PATCH 06/25] feat: default active first item --- src/OptionList.tsx | 2 +- tests/Select.spec.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 7371e774..72f60e49 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -95,7 +95,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // ========================== Get First Selectable Node ========================== const getFirstSelectableNode = (nodes: EventDataNode): EventDataNode | null => { for (const node of nodes) { - if (node.selectable !== false) { + if (node.selectable !== false && !node.disabled) { return node; } if (node[fieldNames.children]) { diff --git a/tests/Select.spec.tsx b/tests/Select.spec.tsx index b0342eb5..d7f52497 100644 --- a/tests/Select.spec.tsx +++ b/tests/Select.spec.tsx @@ -536,7 +536,7 @@ describe('TreeSelect.basic', () => { expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('11 label'); }); - it('should active first option when dropdown is opened', () => { + it('should active first un-disabled option when dropdown is opened', () => { const treeData = [ { key: '0', value: '0', title: '0 label', disabled: true }, { key: '1', value: '1', title: '1 label' }, @@ -551,7 +551,7 @@ describe('TreeSelect.basic', () => { const activeNode = wrapper.find('.rc-tree-select-tree-treenode-active'); expect(activeNode).toHaveLength(1); - expect(activeNode.text()).toBe('0 label'); + expect(activeNode.text()).toBe('1 label'); }); }); From b99d2fb51b3593f41c7b0491a30b475a5bd81cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 16:59:44 +0800 Subject: [PATCH 07/25] refactor: merge active effect logic --- src/OptionList.tsx | 67 ++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 72f60e49..ec9bbcf5 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -76,7 +76,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, (prev, next) => next[0] && prev[1] !== next[1], ); - // ========================== Active ========================== + // ========================== Active Key Effect ========================== const [activeKey, setActiveKey] = React.useState(null); const activeEntity = keyEntities[activeKey as SafeKey]; @@ -93,37 +93,57 @@ const OptionList: React.ForwardRefRenderFunction = (_, }, [checkable, checkedKeys, halfCheckedKeys]); // ========================== Get First Selectable Node ========================== - const getFirstSelectableNode = (nodes: EventDataNode): EventDataNode | null => { + const getFirstMatchingNode = ( + nodes: EventDataNode, + predicate: (node: EventDataNode) => boolean, + ): EventDataNode | null => { for (const node of nodes) { - if (node.selectable !== false && !node.disabled) { + if (predicate(node)) { return node; } if (node[fieldNames.children]) { - const selectableInChildren = getFirstSelectableNode(node[fieldNames.children]); - if (selectableInChildren) { - return selectableInChildren; + const matchInChildren = getFirstMatchingNode(node[fieldNames.children], predicate); + if (matchInChildren) { + return matchInChildren; } } } return null; }; - // ========================== Scroll ========================== + const getFirstSelectableNode = (nodes: EventDataNode): EventDataNode | null => + getFirstMatchingNode(nodes, node => node.selectable !== false && !node.disabled); + + const getFirstMatchNode = (nodes: EventDataNode): EventDataNode | null => + getFirstMatchingNode(nodes, node => filterTreeNode(node) && !node.disabled); + + // ========================== Active Key Effect ========================== React.useEffect(() => { + if (searchValue) { + const firstMatchNode = getFirstMatchNode(memoTreeData); + setActiveKey(firstMatchNode ? firstMatchNode[fieldNames.value] : null); + return; + } + if (open) { - // Single mode should scroll to current key if (!multiple && checkedKeys.length) { - treeRef.current?.scrollTo({ key: checkedKeys[0] }); setActiveKey(checkedKeys[0]); } else { - // Otherwise, activate the first selectable node const firstSelectableNode = getFirstSelectableNode(memoTreeData); if (firstSelectableNode) { setActiveKey(firstSelectableNode[fieldNames.value]); } } + return; + } + setActiveKey(null); + }, [open, searchValue]); + + // ========================== Scroll Effect ========================== + React.useEffect(() => { + if (open && !multiple && checkedKeys.length) { + treeRef.current?.scrollTo({ key: checkedKeys[0] }); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // ========================== Events ========================== @@ -176,34 +196,11 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }; - const getFirstMatchNode = (nodes: EventDataNode): EventDataNode | null => { - for (const node of nodes) { - if (filterTreeNode(node) && !node.disabled) { - return node; - } - if (node[fieldNames.children]) { - const matchInChildren = getFirstMatchNode(node[fieldNames.children]); - if (matchInChildren) { - return matchInChildren; - } - } - } - return null; - }; - - // =========================== Search Effect =========================== + // ========================== Search Effect ========================== React.useEffect(() => { if (searchValue) { setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); - - const firstMatchNode = getFirstMatchNode(memoTreeData); - if (firstMatchNode) { - setActiveKey(firstMatchNode[fieldNames.value]); - } else { - setActiveKey(null); - } } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); // ========================= Keyboard ========================= From 644bf9d7150169c96a2f3b4e53ee7fd1428af660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 17:10:49 +0800 Subject: [PATCH 08/25] chore: adjust code style --- src/OptionList.tsx | 94 +++++++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index ec9bbcf5..724851fe 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -92,53 +92,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, }; }, [checkable, checkedKeys, halfCheckedKeys]); - // ========================== Get First Selectable Node ========================== - const getFirstMatchingNode = ( - nodes: EventDataNode, - predicate: (node: EventDataNode) => boolean, - ): EventDataNode | null => { - for (const node of nodes) { - if (predicate(node)) { - return node; - } - if (node[fieldNames.children]) { - const matchInChildren = getFirstMatchingNode(node[fieldNames.children], predicate); - if (matchInChildren) { - return matchInChildren; - } - } - } - return null; - }; - - const getFirstSelectableNode = (nodes: EventDataNode): EventDataNode | null => - getFirstMatchingNode(nodes, node => node.selectable !== false && !node.disabled); - - const getFirstMatchNode = (nodes: EventDataNode): EventDataNode | null => - getFirstMatchingNode(nodes, node => filterTreeNode(node) && !node.disabled); - - // ========================== Active Key Effect ========================== - React.useEffect(() => { - if (searchValue) { - const firstMatchNode = getFirstMatchNode(memoTreeData); - setActiveKey(firstMatchNode ? firstMatchNode[fieldNames.value] : null); - return; - } - - if (open) { - if (!multiple && checkedKeys.length) { - setActiveKey(checkedKeys[0]); - } else { - const firstSelectableNode = getFirstSelectableNode(memoTreeData); - if (firstSelectableNode) { - setActiveKey(firstSelectableNode[fieldNames.value]); - } - } - return; - } - setActiveKey(null); - }, [open, searchValue]); - // ========================== Scroll Effect ========================== React.useEffect(() => { if (open && !multiple && checkedKeys.length) { @@ -203,6 +156,53 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }, [searchValue]); + // ========================== Get First Selectable Node ========================== + const getFirstMatchingNode = ( + nodes: EventDataNode, + predicate: (node: EventDataNode) => boolean, + ): EventDataNode | null => { + for (const node of nodes) { + if (predicate(node)) { + return node; + } + if (node[fieldNames.children]) { + const matchInChildren = getFirstMatchingNode(node[fieldNames.children], predicate); + if (matchInChildren) { + return matchInChildren; + } + } + } + return null; + }; + + const getFirstSelectableNode = (nodes: EventDataNode): EventDataNode | null => + getFirstMatchingNode(nodes, node => node.selectable !== false && !node.disabled); + + const getFirstMatchNode = (nodes: EventDataNode): EventDataNode | null => + getFirstMatchingNode(nodes, node => filterTreeNode(node) && !node.disabled); + + // ========================== Active Key Effect ========================== + React.useEffect(() => { + if (searchValue) { + const firstMatchNode = getFirstMatchNode(memoTreeData); + setActiveKey(firstMatchNode ? firstMatchNode[fieldNames.value] : null); + return; + } + + if (open) { + if (!multiple && checkedKeys.length) { + setActiveKey(checkedKeys[0]); + } else { + const firstSelectableNode = getFirstSelectableNode(memoTreeData); + if (firstSelectableNode) { + setActiveKey(firstSelectableNode[fieldNames.value]); + } + } + return; + } + setActiveKey(null); + }, [open, searchValue]); + // ========================= Keyboard ========================= React.useImperativeHandle(ref, () => ({ scrollTo: treeRef.current?.scrollTo, From 89900b8c6f0645856bf9e760b945b76b20a4ba9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 17:25:56 +0800 Subject: [PATCH 09/25] fix: type fix --- src/OptionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 724851fe..4deab746 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -158,7 +158,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // ========================== Get First Selectable Node ========================== const getFirstMatchingNode = ( - nodes: EventDataNode, + nodes: EventDataNode[], predicate: (node: EventDataNode) => boolean, ): EventDataNode | null => { for (const node of nodes) { From 2659bd1918ea434e41b4123044458fe78f3eed6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 21:30:26 +0800 Subject: [PATCH 10/25] feat: adjust active effect logic --- examples/basic.tsx | 20 +++++----- src/OptionList.tsx | 68 ++++++++++++++++++-------------- tests/Select.SearchInput.spec.js | 9 ++++- 3 files changed, 57 insertions(+), 40 deletions(-) diff --git a/examples/basic.tsx b/examples/basic.tsx index 4b6b8cb4..4352afbb 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -216,7 +216,6 @@ class Demo extends React.Component { console.log('onPopupScroll:', evt.target); }} /> -

single select (just select children)

-

multiple select

-

check select

-

labelInValue & show path

-

use treeDataSimpleMode

-

Testing in extreme conditions (Boundary conditions test)

console.log(val, ...args)} /> -

use TreeNode Component (not recommend)

-

title render

- open + // open style={{ width: 300 }} treeData={gData} treeTitleRender={node => node.label + 'ok'} /> +

disabled node

+ console.log(val)} + treeData={[ + { value: '1', label: '1' }, + { value: '2', label: '2', disabled: true }, + { value: '3', label: '3' }, + ]} + />
); } diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 4deab746..9e53df49 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -159,48 +159,58 @@ const OptionList: React.ForwardRefRenderFunction = (_, // ========================== Get First Selectable Node ========================== const getFirstMatchingNode = ( nodes: EventDataNode[], - predicate: (node: EventDataNode) => boolean, + searchValue?: string, ): EventDataNode | null => { - for (const node of nodes) { - if (predicate(node)) { - return node; - } - if (node[fieldNames.children]) { - const matchInChildren = getFirstMatchingNode(node[fieldNames.children], predicate); - if (matchInChildren) { - return matchInChildren; + const findNode = (nodeList: EventDataNode[]): EventDataNode | null => { + for (const node of nodeList) { + if (node.disabled || node.selectable === false) { + continue; } - } - } - return null; - }; - const getFirstSelectableNode = (nodes: EventDataNode): EventDataNode | null => - getFirstMatchingNode(nodes, node => node.selectable !== false && !node.disabled); + if (searchValue) { + if (filterTreeNode(node)) { + return node; + } + } else if (!node.disabled && node.selectable !== false) { + return node; + } - const getFirstMatchNode = (nodes: EventDataNode): EventDataNode | null => - getFirstMatchingNode(nodes, node => filterTreeNode(node) && !node.disabled); + if (node[fieldNames.children]) { + const matchInChildren = findNode(node[fieldNames.children]); + if (matchInChildren) { + return matchInChildren; + } + } + } + return null; + }; + + return findNode(nodes); + }; // ========================== Active Key Effect ========================== React.useEffect(() => { + if (!open) { + setActiveKey(null); + return; + } + + // Prioritize activating the searched node if (searchValue) { - const firstMatchNode = getFirstMatchNode(memoTreeData); - setActiveKey(firstMatchNode ? firstMatchNode[fieldNames.value] : null); + const firstNode = getFirstMatchingNode(memoTreeData, searchValue); + setActiveKey(firstNode ? firstNode[fieldNames.value] : null); return; } - if (open) { - if (!multiple && checkedKeys.length) { - setActiveKey(checkedKeys[0]); - } else { - const firstSelectableNode = getFirstSelectableNode(memoTreeData); - if (firstSelectableNode) { - setActiveKey(firstSelectableNode[fieldNames.value]); - } - } + // If no search value, activate the first checked node + if (!multiple && checkedKeys.length) { + setActiveKey(checkedKeys[0]); return; } - setActiveKey(null); + + // If no search value and no checked nodes, activate the first node + const firstNode = getFirstMatchingNode(memoTreeData, ''); + setActiveKey(firstNode ? firstNode[fieldNames.value] : null); }, [open, searchValue]); // ========================= Keyboard ========================= diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index 49cec2cf..8affed64 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -206,8 +206,8 @@ describe('TreeSelect.SearchInput', () => { const wrapper = mount( { wrapper.search('1'); wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); - onSelect.mockReset(); // Search disabled node and press enter, should not select wrapper.search('2'); wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); expect(onSelect).not.toHaveBeenCalled(); + onSelect.mockReset(); + + wrapper.search('3'); + wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + expect(onSelect).toHaveBeenCalledWith('3', expect.anything()); + onSelect.mockReset(); }); it('should not select node when no matches found', () => { From bf62a4245dd7350600b98382439f0605aa488cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 21:32:11 +0800 Subject: [PATCH 11/25] fix: lint fix --- src/OptionList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 9e53df49..a43a6642 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -159,7 +159,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // ========================== Get First Selectable Node ========================== const getFirstMatchingNode = ( nodes: EventDataNode[], - searchValue?: string, + searchVal?: string, ): EventDataNode | null => { const findNode = (nodeList: EventDataNode[]): EventDataNode | null => { for (const node of nodeList) { @@ -167,7 +167,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, continue; } - if (searchValue) { + if (searchVal) { if (filterTreeNode(node)) { return node; } From e3ef3e5e45df76ddeff7332d8b27a617578cce66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 21:34:56 +0800 Subject: [PATCH 12/25] chore: remove useless code --- examples/basic.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/examples/basic.tsx b/examples/basic.tsx index 4352afbb..26598fac 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -384,16 +384,6 @@ class Demo extends React.Component { treeData={gData} treeTitleRender={node => node.label + 'ok'} /> -

disabled node

- console.log(val)} - treeData={[ - { value: '1', label: '1' }, - { value: '2', label: '2', disabled: true }, - { value: '3', label: '3' }, - ]} - />
); } From 516d0b8d7ee4eb6078a759c7a37d86516878bddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 22:11:08 +0800 Subject: [PATCH 13/25] feat: flatten tree to match first node --- .vscode/settings.json | 1 + src/OptionList.tsx | 40 +++++++++++++++++----------------------- 2 files changed, 18 insertions(+), 23 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/OptionList.tsx b/src/OptionList.tsx index a43a6642..465acbf6 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -10,6 +10,7 @@ import LegacyContext from './LegacyContext'; import TreeSelectContext from './TreeSelectContext'; import type { Key, SafeKey } from './interface'; import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; +import { flattenTreeData } from 'rc-tree/lib/utils/treeUtil'; const HIDDEN_STYLE = { width: 0, @@ -76,7 +77,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, (prev, next) => next[0] && prev[1] !== next[1], ); - // ========================== Active Key Effect ========================== + // ========================== Active Key ========================== const [activeKey, setActiveKey] = React.useState(null); const activeEntity = keyEntities[activeKey as SafeKey]; @@ -161,31 +162,24 @@ const OptionList: React.ForwardRefRenderFunction = (_, nodes: EventDataNode[], searchVal?: string, ): EventDataNode | null => { - const findNode = (nodeList: EventDataNode[]): EventDataNode | null => { - for (const node of nodeList) { - if (node.disabled || node.selectable === false) { - continue; - } - - if (searchVal) { - if (filterTreeNode(node)) { - return node; - } - } else if (!node.disabled && node.selectable !== false) { - return node; - } + // Flatten the tree structure + const flattenedNodes = flattenTreeData(nodes, true, fieldNames); + + // Iterate through the flattened array to find the first matching node + const matchedNode = flattenedNodes.find(node => { + const rawNode = node.data as EventDataNode; + if (rawNode.disabled || rawNode.selectable === false) { + return false; + } - if (node[fieldNames.children]) { - const matchInChildren = findNode(node[fieldNames.children]); - if (matchInChildren) { - return matchInChildren; - } - } + if (searchVal) { + return filterTreeNode(rawNode); } - return null; - }; - return findNode(nodes); + return true; + }); + + return matchedNode ? (matchedNode.data as EventDataNode) : null; }; // ========================== Active Key Effect ========================== From 750de00d7c76ef26a8606d9135385e1597e22270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 23:25:18 +0800 Subject: [PATCH 14/25] chore: add .vscode to gitignore file --- .gitignore | 1 + .vscode/settings.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index de142666..99affc80 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ dist build lib coverage +.vscode yarn.lock package-lock.json es diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0967ef42..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1 +0,0 @@ -{} From 945f1287b3462847e5d8671ca9c95c86bc59d63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 23:39:22 +0800 Subject: [PATCH 15/25] feat: improve flatten treeData --- src/OptionList.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 465acbf6..08f22503 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -162,10 +162,16 @@ const OptionList: React.ForwardRefRenderFunction = (_, nodes: EventDataNode[], searchVal?: string, ): EventDataNode | null => { - // Flatten the tree structure - const flattenedNodes = flattenTreeData(nodes, true, fieldNames); + // 使用 mergedExpandedKeys 而不是 true + // 这样可以保持与实际展示状态一致 + const flattenedNodes = flattenTreeData( + nodes, + // 搜索时展开所有节点以便搜索,否则使用当前展开状态 + searchVal ? true : mergedExpandedKeys, + fieldNames, + ); - // Iterate through the flattened array to find the first matching node + // 查找第一个匹配的节点 const matchedNode = flattenedNodes.find(node => { const rawNode = node.data as EventDataNode; if (rawNode.disabled || rawNode.selectable === false) { From 467b05611c26eee880942089dcac61c3692df6c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 23:44:01 +0800 Subject: [PATCH 16/25] feat: improve flatten treeData --- src/OptionList.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 08f22503..cc87a017 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -162,16 +162,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, nodes: EventDataNode[], searchVal?: string, ): EventDataNode | null => { - // 使用 mergedExpandedKeys 而不是 true - // 这样可以保持与实际展示状态一致 - const flattenedNodes = flattenTreeData( - nodes, - // 搜索时展开所有节点以便搜索,否则使用当前展开状态 - searchVal ? true : mergedExpandedKeys, - fieldNames, - ); + const flattenedNodes = flattenTreeData(nodes, mergedExpandedKeys, fieldNames); - // 查找第一个匹配的节点 const matchedNode = flattenedNodes.find(node => { const rawNode = node.data as EventDataNode; if (rawNode.disabled || rawNode.selectable === false) { From 37d7cfca79b4ab5bb99414086ce547402ae11530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 5 Nov 2024 23:46:24 +0800 Subject: [PATCH 17/25] chore: adjust code style --- examples/basic.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/basic.tsx b/examples/basic.tsx index 26598fac..56454051 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -379,7 +379,7 @@ class Demo extends React.Component {

title render

- // open + open style={{ width: 300 }} treeData={gData} treeTitleRender={node => node.label + 'ok'} From 86cf480a96aecb8d296ef919182afa0120d7eb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 6 Nov 2024 00:17:34 +0800 Subject: [PATCH 18/25] perf: optimize tree node searching with flattened data --- src/OptionList.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index cc87a017..fe5dcefb 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -157,14 +157,15 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }, [searchValue]); - // ========================== Get First Selectable Node ========================== - const getFirstMatchingNode = ( - nodes: EventDataNode[], - searchVal?: string, - ): EventDataNode | null => { - const flattenedNodes = flattenTreeData(nodes, mergedExpandedKeys, fieldNames); + // ========================== Flatten Tree Data ========================== + const flattenedTreeData = React.useMemo(() => { + const expandKeys = searchValue ? true : mergedExpandedKeys; + return flattenTreeData(memoTreeData, expandKeys, fieldNames); + }, [memoTreeData, searchValue, mergedExpandedKeys]); - const matchedNode = flattenedNodes.find(node => { + // ========================== Get First Selectable Node ========================== + const getFirstMatchingNode = (searchVal?: string): EventDataNode | null => { + const matchedNode = flattenedTreeData.find(node => { const rawNode = node.data as EventDataNode; if (rawNode.disabled || rawNode.selectable === false) { return false; @@ -189,7 +190,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // Prioritize activating the searched node if (searchValue) { - const firstNode = getFirstMatchingNode(memoTreeData, searchValue); + const firstNode = getFirstMatchingNode(searchValue); setActiveKey(firstNode ? firstNode[fieldNames.value] : null); return; } @@ -201,7 +202,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, } // If no search value and no checked nodes, activate the first node - const firstNode = getFirstMatchingNode(memoTreeData, ''); + const firstNode = getFirstMatchingNode(); setActiveKey(firstNode ? firstNode[fieldNames.value] : null); }, [open, searchValue]); From 134f4f4fbdceddd59b4d2c330aaa3f86247870f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 6 Nov 2024 09:46:31 +0800 Subject: [PATCH 19/25] chore: add comment --- src/OptionList.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index fe5dcefb..e514d458 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -95,9 +95,11 @@ const OptionList: React.ForwardRefRenderFunction = (_, // ========================== Scroll Effect ========================== React.useEffect(() => { + // Single mode should scroll to current key if (open && !multiple && checkedKeys.length) { treeRef.current?.scrollTo({ key: checkedKeys[0] }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // ========================== Events ========================== @@ -155,13 +157,13 @@ const OptionList: React.ForwardRefRenderFunction = (_, if (searchValue) { setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); // ========================== Flatten Tree Data ========================== const flattenedTreeData = React.useMemo(() => { - const expandKeys = searchValue ? true : mergedExpandedKeys; - return flattenTreeData(memoTreeData, expandKeys, fieldNames); - }, [memoTreeData, searchValue, mergedExpandedKeys]); + return flattenTreeData(memoTreeData, mergedExpandedKeys, fieldNames); + }, [memoTreeData, mergedExpandedKeys]); // ========================== Get First Selectable Node ========================== const getFirstMatchingNode = (searchVal?: string): EventDataNode | null => { From 5d29cc06e7431ec8c38f5932d2d79d2a2cb2dfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 6 Nov 2024 12:42:43 +0800 Subject: [PATCH 20/25] revert: restore recursive node search --- src/OptionList.tsx | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index e514d458..05d0b1ca 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -160,27 +160,32 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); - // ========================== Flatten Tree Data ========================== - const flattenedTreeData = React.useMemo(() => { - return flattenTreeData(memoTreeData, mergedExpandedKeys, fieldNames); - }, [memoTreeData, mergedExpandedKeys]); - // ========================== Get First Selectable Node ========================== - const getFirstMatchingNode = (searchVal?: string): EventDataNode | null => { - const matchedNode = flattenedTreeData.find(node => { - const rawNode = node.data as EventDataNode; - if (rawNode.disabled || rawNode.selectable === false) { - return false; + const getFirstMatchingNode = ( + nodes: EventDataNode[], + searchVal?: string, + ): EventDataNode | null => { + for (const node of nodes) { + if (node.disabled || node.selectable === false) { + continue; } if (searchVal) { - return filterTreeNode(rawNode); + if (filterTreeNode(node)) { + return node; + } + } else { + return node; } - return true; - }); - - return matchedNode ? (matchedNode.data as EventDataNode) : null; + if (node[fieldNames.children]) { + const matchInChildren = getFirstMatchingNode(node[fieldNames.children], searchVal); + if (matchInChildren) { + return matchInChildren; + } + } + } + return null; }; // ========================== Active Key Effect ========================== @@ -192,7 +197,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // Prioritize activating the searched node if (searchValue) { - const firstNode = getFirstMatchingNode(searchValue); + const firstNode = getFirstMatchingNode(treeData, searchValue); setActiveKey(firstNode ? firstNode[fieldNames.value] : null); return; } @@ -204,7 +209,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, } // If no search value and no checked nodes, activate the first node - const firstNode = getFirstMatchingNode(); + const firstNode = getFirstMatchingNode(treeData); setActiveKey(firstNode ? firstNode[fieldNames.value] : null); }, [open, searchValue]); From 4640c4d76e9031938b1ea6b46a87740e795cf10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 6 Nov 2024 16:44:09 +0800 Subject: [PATCH 21/25] chore: remove unnecessary logic --- examples/basic.tsx | 8 +++++++ src/OptionList.tsx | 55 +++++++++++++++++----------------------------- 2 files changed, 28 insertions(+), 35 deletions(-) diff --git a/examples/basic.tsx b/examples/basic.tsx index 56454051..4b6b8cb4 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -216,6 +216,7 @@ class Demo extends React.Component { console.log('onPopupScroll:', evt.target); }} /> +

single select (just select children)

+

multiple select

+

check select

+

labelInValue & show path

+

use treeDataSimpleMode

+

Testing in extreme conditions (Boundary conditions test)

console.log(val, ...args)} /> +

use TreeNode Component (not recommend)

+

title render

open diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 05d0b1ca..6fb4268e 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -10,7 +10,6 @@ import LegacyContext from './LegacyContext'; import TreeSelectContext from './TreeSelectContext'; import type { Key, SafeKey } from './interface'; import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; -import { flattenTreeData } from 'rc-tree/lib/utils/treeUtil'; const HIDDEN_STYLE = { width: 0, @@ -77,10 +76,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, (prev, next) => next[0] && prev[1] !== next[1], ); - // ========================== Active Key ========================== - const [activeKey, setActiveKey] = React.useState(null); - const activeEntity = keyEntities[activeKey as SafeKey]; - // ========================== Values ========================== const mergedCheckedKeys = React.useMemo(() => { if (!checkable) { @@ -93,7 +88,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, }; }, [checkable, checkedKeys, halfCheckedKeys]); - // ========================== Scroll Effect ========================== + // ========================== Scroll ========================== React.useEffect(() => { // Single mode should scroll to current key if (open && !multiple && checkedKeys.length) { @@ -123,15 +118,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }; - // ========================== Search ========================== - const lowerSearchValue = String(searchValue).toLowerCase(); - const filterTreeNode = (treeNode: EventDataNode) => { - if (!lowerSearchValue) { - return false; - } - return String(treeNode[treeNodeFilterProp]).toLowerCase().includes(lowerSearchValue); - }; - // =========================== Keys =========================== const [expandedKeys, setExpandedKeys] = React.useState(treeDefaultExpandedKeys); const [searchExpandedKeys, setSearchExpandedKeys] = React.useState(null); @@ -152,7 +138,15 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }; - // ========================== Search Effect ========================== + // ========================== Search ========================== + const lowerSearchValue = String(searchValue).toLowerCase(); + const filterTreeNode = (treeNode: EventDataNode) => { + if (!lowerSearchValue) { + return false; + } + return String(treeNode[treeNodeFilterProp]).toLowerCase().includes(lowerSearchValue); + }; + React.useEffect(() => { if (searchValue) { setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); @@ -161,25 +155,14 @@ const OptionList: React.ForwardRefRenderFunction = (_, }, [searchValue]); // ========================== Get First Selectable Node ========================== - const getFirstMatchingNode = ( - nodes: EventDataNode[], - searchVal?: string, - ): EventDataNode | null => { + const getFirstMatchingNode = (nodes: EventDataNode[]): EventDataNode | null => { for (const node of nodes) { - if (node.disabled || node.selectable === false) { - continue; - } - - if (searchVal) { - if (filterTreeNode(node)) { - return node; - } - } else { + if (!node.disabled && node.selectable !== false) { return node; } if (node[fieldNames.children]) { - const matchInChildren = getFirstMatchingNode(node[fieldNames.children], searchVal); + const matchInChildren = getFirstMatchingNode(node[fieldNames.children]); if (matchInChildren) { return matchInChildren; } @@ -188,16 +171,18 @@ const OptionList: React.ForwardRefRenderFunction = (_, return null; }; - // ========================== Active Key Effect ========================== + // ========================== Active ========================== + const [activeKey, setActiveKey] = React.useState(null); + const activeEntity = keyEntities[activeKey as SafeKey]; + React.useEffect(() => { if (!open) { - setActiveKey(null); return; } - // Prioritize activating the searched node + // // Prioritize activating the searched node if (searchValue) { - const firstNode = getFirstMatchingNode(treeData, searchValue); + const firstNode = getFirstMatchingNode(memoTreeData); setActiveKey(firstNode ? firstNode[fieldNames.value] : null); return; } @@ -209,7 +194,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, } // If no search value and no checked nodes, activate the first node - const firstNode = getFirstMatchingNode(treeData); + const firstNode = getFirstMatchingNode(memoTreeData); setActiveKey(firstNode ? firstNode[fieldNames.value] : null); }, [open, searchValue]); From 229f4f735636cdf65030b9ca47d1b2e0238a8dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 6 Nov 2024 16:53:39 +0800 Subject: [PATCH 22/25] chore: remove unnecessary logic --- src/OptionList.tsx | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 6fb4268e..9cfa34e6 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -179,23 +179,27 @@ const OptionList: React.ForwardRefRenderFunction = (_, if (!open) { return; } + let nextActiveKey = null; - // // Prioritize activating the searched node - if (searchValue) { + const getFirstNode = () => { const firstNode = getFirstMatchingNode(memoTreeData); - setActiveKey(firstNode ? firstNode[fieldNames.value] : null); - return; - } + return firstNode ? firstNode[fieldNames.value] : null; + }; - // If no search value, activate the first checked node - if (!multiple && checkedKeys.length) { - setActiveKey(checkedKeys[0]); - return; + // search mode active first node + if (searchValue) { + nextActiveKey = getFirstNode(); + } + // single mode active first checked node + else if (!multiple && checkedKeys.length) { + nextActiveKey = checkedKeys[0]; + } + // default active first node + else { + nextActiveKey = getFirstNode(); } - // If no search value and no checked nodes, activate the first node - const firstNode = getFirstMatchingNode(memoTreeData); - setActiveKey(firstNode ? firstNode[fieldNames.value] : null); + setActiveKey(nextActiveKey); }, [open, searchValue]); // ========================= Keyboard ========================= From 9c7ad2775328987f9c461e1b96b4f324231043ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Wed, 6 Nov 2024 17:54:25 +0800 Subject: [PATCH 23/25] chore: adjust logic --- src/OptionList.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 9cfa34e6..fd854af8 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -186,16 +186,10 @@ const OptionList: React.ForwardRefRenderFunction = (_, return firstNode ? firstNode[fieldNames.value] : null; }; - // search mode active first node - if (searchValue) { - nextActiveKey = getFirstNode(); - } // single mode active first checked node - else if (!multiple && checkedKeys.length) { + if (!multiple && checkedKeys.length && !searchValue) { nextActiveKey = checkedKeys[0]; - } - // default active first node - else { + } else { nextActiveKey = getFirstNode(); } From 6f5a84c7aea04b7c4386a72ffcc8474efeeb7e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 7 Nov 2024 17:59:46 +0800 Subject: [PATCH 24/25] test: use testing-library --- tests/Select.SearchInput.spec.js | 44 ++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index 8affed64..8bfb0862 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -1,6 +1,7 @@ /* eslint-disable no-undef */ import React, { useState } from 'react'; import { mount } from 'enzyme'; +import { render, fireEvent } from '@testing-library/react'; import TreeSelect, { TreeNode } from '../src'; import KeyCode from 'rc-util/lib/KeyCode'; @@ -203,7 +204,7 @@ describe('TreeSelect.SearchInput', () => { describe('keyboard events', () => { it('should select first matched node when press enter', () => { const onSelect = jest.fn(); - const wrapper = mount( + const { getByRole } = render( { ); // Search and press enter, should select first matched non-disabled node - wrapper.search('1'); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + const input = getByRole('combobox'); + fireEvent.change(input, { target: { value: '1' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); onSelect.mockReset(); // Search disabled node and press enter, should not select - wrapper.search('2'); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + fireEvent.change(input, { target: { value: '2' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); expect(onSelect).not.toHaveBeenCalled(); onSelect.mockReset(); - wrapper.search('3'); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + fireEvent.change(input, { target: { value: '3' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); expect(onSelect).toHaveBeenCalledWith('3', expect.anything()); - onSelect.mockReset(); }); it('should not select node when no matches found', () => { const onSelect = jest.fn(); - const wrapper = mount( + const { getByRole } = render( { ); // Search non-existent value and press enter, should not select any node - wrapper.search('not-exist'); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + const input = getByRole('combobox'); + fireEvent.change(input, { target: { value: 'not-exist' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); expect(onSelect).not.toHaveBeenCalled(); }); it('should ignore enter press when all matched nodes are disabled', () => { const onSelect = jest.fn(); - const wrapper = mount( + const { getByRole } = render( { ); // When all matched nodes are disabled, press enter should not select any node - wrapper.search('1'); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ENTER }); + const input = getByRole('combobox'); + fireEvent.change(input, { target: { value: '1' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); expect(onSelect).not.toHaveBeenCalled(); }); it('should activate first matched node when searching', () => { - const wrapper = mount( + const { getByRole, container } = render( { ); // When searching, first matched non-disabled node should be activated - wrapper.search('1'); - expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('1'); + const input = getByRole('combobox'); + fireEvent.change(input, { target: { value: '1' } }); + expect(container.querySelector('.rc-tree-select-tree-treenode-active')).toHaveTextContent( + '1', + ); // Should skip disabled nodes - wrapper.search('2'); - expect(wrapper.find('.rc-tree-select-tree-treenode-active').length).toBe(0); + fireEvent.change(input, { target: { value: '2' } }); + expect(container.querySelectorAll('.rc-tree-select-tree-treenode-active')).toHaveLength(0); }); }); }); From 97bde6c8a67c09630faf8d3af82a33a5ef025d53 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 19:19:21 +0800 Subject: [PATCH 25/25] fix: keep active matched item when search --- src/OptionList.tsx | 10 +++++++++- tests/Select.SearchInput.spec.js | 4 ---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index fd854af8..aaa9d0ee 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -157,7 +157,15 @@ const OptionList: React.ForwardRefRenderFunction = (_, // ========================== Get First Selectable Node ========================== const getFirstMatchingNode = (nodes: EventDataNode[]): EventDataNode | null => { for (const node of nodes) { - if (!node.disabled && node.selectable !== false) { + if (node.disabled || node.selectable === false) { + continue; + } + + if (searchValue) { + if (filterTreeNode(node)) { + return node; + } + } else { return node; } diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index 8bfb0862..77fced28 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -229,10 +229,6 @@ describe('TreeSelect.SearchInput', () => { fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); expect(onSelect).not.toHaveBeenCalled(); onSelect.mockReset(); - - fireEvent.change(input, { target: { value: '3' } }); - fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); - expect(onSelect).toHaveBeenCalledWith('3', expect.anything()); }); it('should not select node when no matches found', () => {