Skip to content

Commit

Permalink
feat(a11y): enhance keyboard interaction in search mode (#592)
Browse files Browse the repository at this point in the history
* feat(a11y): enhance keyboard interaction in search mode

* refactor(a11y): synchronize activeKey with search state

* fix: lint fix

* feat: default active first item

* chore: remove useless test case

* feat: default active first item

* refactor: merge active effect logic

* chore: adjust code style

* fix: type fix

* feat: adjust active effect logic

* fix: lint fix

* chore: remove useless code

* feat: flatten tree to match first node

* chore: add .vscode to gitignore file

* feat: improve flatten treeData

* feat: improve flatten treeData

* chore: adjust code style

* perf: optimize tree node searching with flattened data

* chore: add comment

* revert: restore recursive node search

* chore: remove unnecessary logic

* chore: remove unnecessary logic

* chore: adjust logic

* test: use testing-library

* fix: keep active matched item when search
  • Loading branch information
aojunhao123 authored Nov 11, 2024
1 parent 919dced commit a16f0f0
Show file tree
Hide file tree
Showing 6 changed files with 291 additions and 77 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dist
build
lib
coverage
.vscode
yarn.lock
package-lock.json
es
Expand Down
113 changes: 79 additions & 34 deletions src/OptionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,6 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
(prev, next) => next[0] && prev[1] !== next[1],
);

// ========================== Active ==========================
const [activeKey, setActiveKey] = React.useState<Key>(null);
const activeEntity = keyEntities[activeKey as SafeKey];

// ========================== Values ==========================
const mergedCheckedKeys = React.useMemo(() => {
if (!checkable) {
Expand All @@ -97,18 +93,29 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
// 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<any>) => {
if (!lowerSearchValue) {
return false;
// ========================== Events ==========================
const onListMouseDown: React.MouseEventHandler<HTMLDivElement> = 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 ===========================
Expand All @@ -122,13 +129,6 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
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);
Expand All @@ -138,26 +138,71 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
}
};

// ========================== Events ==========================
const onListMouseDown: React.MouseEventHandler<HTMLDivElement> = event => {
event.preventDefault();
// ========================== Search ==========================
const lowerSearchValue = String(searchValue).toLowerCase();
const filterTreeNode = (treeNode: EventDataNode<any>) => {
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<any>[]): EventDataNode<any> | 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<Key>(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, () => ({
Expand All @@ -176,8 +221,8 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
// >>> 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),
Expand All @@ -197,10 +242,10 @@ const OptionList: React.ForwardRefRenderFunction<ReviseRefOptionListProps> = (_,
}));

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 ==========================
Expand Down
100 changes: 100 additions & 0 deletions tests/Select.SearchInput.spec.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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(
<TreeSelect
showSearch
open
onSelect={onSelect}
treeData={[
{ value: '1', label: '1' },
{ value: '2', label: '2', disabled: true },
{ value: '3', label: '3' },
]}
/>,
);

// 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(
<TreeSelect
showSearch
onSelect={onSelect}
open
treeData={[
{ value: '1', label: '1' },
{ value: '2', label: '2' },
]}
/>,
);

// 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(
<TreeSelect
showSearch
onSelect={onSelect}
open
treeData={[
{ value: '1', label: '1', disabled: true },
{ value: '2', label: '2', disabled: true },
]}
/>,
);

// 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(
<TreeSelect
showSearch
open
treeData={[
{ value: '1', label: '1' },
{ value: '2', label: '2', disabled: true },
{ value: '3', label: '3' },
]}
/>,
);

// 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);
});
});
});
44 changes: 23 additions & 21 deletions tests/Select.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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(<TreeSelect treeData={treeData} />);

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', () => {
Expand Down Expand Up @@ -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(
<TreeSelect open treeDefaultExpandAll multiple onSelect={onSelect}>
<TreeNode value="parent 1-0" title="parent 1-0">
<TreeNode value="leaf1" title="my leaf" disabled />
<TreeNode value="leaf2" title="your leaf" disabled />
</TreeNode>
</TreeSelect>,
);

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();

Expand Down
Loading

0 comments on commit a16f0f0

Please sign in to comment.