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/src/OptionList.tsx b/src/OptionList.tsx index 10e99760..aaa9d0ee 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -76,10 +76,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, (prev, next) => next[0] && prev[1] !== next[1], ); - // ========================== Active ========================== - const [activeKey, setActiveKey] = React.useState(null); - const activeEntity = keyEntities[activeKey as SafeKey]; - // ========================== Values ========================== const mergedCheckedKeys = React.useMemo(() => { if (!checkable) { @@ -97,18 +93,29 @@ const OptionList: React.ForwardRefRenderFunction = (_, // Single mode should scroll to current key if (open && !multiple && checkedKeys.length) { treeRef.current?.scrollTo({ key: checkedKeys[0] }); - setActiveKey(checkedKeys[0]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - // ========================== Search ========================== - const lowerSearchValue = String(searchValue).toLowerCase(); - const filterTreeNode = (treeNode: EventDataNode) => { - if (!lowerSearchValue) { - return false; + // ========================== Events ========================== + const onListMouseDown: React.MouseEventHandler = event => { + event.preventDefault(); + }; + + const onInternalSelect = (__: Key[], info: TreeEventInfo) => { + const { node } = info; + + if (checkable && isCheckDisabled(node)) { + return; + } + + onSelect(node.key, { + selected: !checkedKeys.includes(node.key), + }); + + if (!multiple) { + toggleOpen(false); } - return String(treeNode[treeNodeFilterProp]).toLowerCase().includes(lowerSearchValue); }; // =========================== Keys =========================== @@ -122,13 +129,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, return searchValue ? searchExpandedKeys : expandedKeys; }, [expandedKeys, searchExpandedKeys, treeExpandedKeys, searchValue]); - React.useEffect(() => { - if (searchValue) { - setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchValue]); - const onInternalExpand = (keys: Key[]) => { setExpandedKeys(keys); setSearchExpandedKeys(keys); @@ -138,26 +138,71 @@ const OptionList: React.ForwardRefRenderFunction = (_, } }; - // ========================== Events ========================== - const onListMouseDown: React.MouseEventHandler = event => { - event.preventDefault(); + // ========================== Search ========================== + const lowerSearchValue = String(searchValue).toLowerCase(); + const filterTreeNode = (treeNode: EventDataNode) => { + if (!lowerSearchValue) { + return false; + } + return String(treeNode[treeNodeFilterProp]).toLowerCase().includes(lowerSearchValue); }; - const onInternalSelect = (__: Key[], info: TreeEventInfo) => { - const { node } = info; + React.useEffect(() => { + if (searchValue) { + setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchValue]); - if (checkable && isCheckDisabled(node)) { + // ========================== Get First Selectable Node ========================== + const getFirstMatchingNode = (nodes: EventDataNode[]): EventDataNode | null => { + for (const node of nodes) { + if (node.disabled || node.selectable === false) { + continue; + } + + if (searchValue) { + if (filterTreeNode(node)) { + return node; + } + } else { + return node; + } + + if (node[fieldNames.children]) { + const matchInChildren = getFirstMatchingNode(node[fieldNames.children]); + if (matchInChildren) { + return matchInChildren; + } + } + } + return null; + }; + + // ========================== Active ========================== + const [activeKey, setActiveKey] = React.useState(null); + const activeEntity = keyEntities[activeKey as SafeKey]; + + React.useEffect(() => { + if (!open) { return; } + let nextActiveKey = null; - onSelect(node.key, { - selected: !checkedKeys.includes(node.key), - }); + const getFirstNode = () => { + const firstNode = getFirstMatchingNode(memoTreeData); + return firstNode ? firstNode[fieldNames.value] : null; + }; - if (!multiple) { - toggleOpen(false); + // 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, () => ({ @@ -176,8 +221,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), @@ -197,10 +242,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..77fced28 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -1,7 +1,9 @@ /* 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'; describe('TreeSelect.SearchInput', () => { it('select item will clean searchInput', () => { @@ -198,4 +200,102 @@ 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 { getByRole } = render( + , + ); + + // Search and press enter, should select first matched non-disabled node + 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 + fireEvent.change(input, { target: { value: '2' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + expect(onSelect).not.toHaveBeenCalled(); + onSelect.mockReset(); + }); + + it('should not select node when no matches found', () => { + const onSelect = jest.fn(); + const { getByRole } = render( + , + ); + + // Search non-existent value and press enter, should not select any node + 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 { getByRole } = render( + , + ); + + // When all matched nodes are disabled, press enter should not select any node + 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 { getByRole, container } = render( + , + ); + + // When searching, first matched non-disabled node should be activated + 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 + fireEvent.change(input, { target: { value: '2' } }); + expect(container.querySelectorAll('.rc-tree-select-tree-treenode-active')).toHaveLength(0); + }); + }); }); diff --git a/tests/Select.spec.tsx b/tests/Select.spec.tsx index a7f321e7..d7f52497 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 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' }, + { 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('1 label'); + }); }); it('click in list should preventDefault', () => { @@ -591,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(); diff --git a/tests/__snapshots__/Select.checkable.spec.tsx.snap b/tests/__snapshots__/Select.checkable.spec.tsx.snap index 1f498737..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 +
@@ -660,7 +678,7 @@ exports[`TreeSelect.checkable uncheck remove by tree check 1`] = ` >
+ + 0 +
@@ -861,7 +885,7 @@ exports[`TreeSelect.checkable uncheck remove by tree check 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 +
@@ -780,7 +804,7 @@ exports[`TreeSelect.basic search nodes check tree changed by filter 1`] = ` >
+ + a +
@@ -896,7 +926,7 @@ exports[`TreeSelect.basic search nodes check tree changed by filter 2`] = ` >
+ + a +
@@ -1038,7 +1074,7 @@ exports[`TreeSelect.basic search nodes filter node but not remove then 1`] = ` >
+ + a +
@@ -1178,7 +1220,7 @@ exports[`TreeSelect.basic search nodes renders search input 1`] = ` >