-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[feat]: add a gio-cascader component (#469)
* feat: wip cascader * feat: 级联选择器 * fix: classname typo * chore: remove useless interface key * feat: wip cascader * feat: 级联选择器 * fix: classname typo * chore: remove useless interface key * feat: all features now works well * chore: delete file * chore: something update * chore: 小优化 * test: write test cases * test: write test cases * test: write test cases * docs: add docs * chore: rewrite test case * chore: 适配依赖改动 * chore: remove rc-dropdown dependency * chore: 修复类型错误引发的打包问题 * chore: 修复类型错误引发的打包问题 * chore: 修复意外改动 * chore: something update * style: update * chore: set default placeholder * chore: 小优化 * fix: revert alert less * fix: menu 改为绝对定位,处理beforeSelect reject 的情况 * docs: fix typo
- Loading branch information
Showing
25 changed files
with
1,461 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
182 changes: 182 additions & 0 deletions
182
packages/components/src/components/cascader/__test__/cascader.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
import { act } from 'react-dom/test-utils'; | ||
import { mount } from 'enzyme'; | ||
import { waitFor } from '@testing-library/react'; | ||
import React from 'react'; | ||
|
||
import { NodeData } from '../menu-item'; | ||
import Cascader from '..'; | ||
|
||
const menu = [ | ||
{ label: 'a', value: 'a' }, | ||
{ label: 'b', value: 'b', children: [{ label: 'b-1', value: 'b-1' }] }, | ||
]; | ||
|
||
describe('<Cascader />', () => { | ||
it('should render a DOM', () => { | ||
const wrapper = mount(<Cascader className="test-cls" title="bar" placeholder="title ph" />); | ||
expect(wrapper.find('.gio-cascader')).toHaveLength(1); | ||
|
||
expect(wrapper.find('.gio-cascader.test-cls')).toHaveLength(1); | ||
|
||
expect(wrapper.find('.gio-cascader-title input').getElement().props.value).toBe('bar'); | ||
|
||
expect(wrapper.find('.gio-cascader-title input').getElement().props.placeholder).toBe('title ph'); | ||
|
||
wrapper.setProps({ prefixCls: 'foo' }); | ||
expect(wrapper.find('.foo')).toHaveLength(1); | ||
}); | ||
|
||
it('should popup a searchable menu overlayer', async () => { | ||
const map = {} as { [key: string]: EventListenerOrEventListenerObject }; | ||
document.addEventListener = jest.fn((event, cb) => { | ||
map[event] = cb; | ||
}); | ||
const wrapper = mount(<Cascader visible keyword="a" searchPlaceholder="search" dataSource={menu} />); | ||
|
||
expect(document.querySelectorAll('.gio-dropdown.gio-cascader-dropdown')).toHaveLength(1); | ||
expect(wrapper.find('.cascader-menu-header input').getElement().props.value).toBe('a'); | ||
expect(wrapper.find('.cascader-menu-list').text()).toBe('a'); | ||
|
||
act(() => { | ||
wrapper.setProps({ keyword: 'b' }); | ||
}); | ||
await waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toBe('b'); | ||
}); | ||
act(() => { | ||
wrapper.setProps({ keyword: '' }); | ||
}); | ||
await waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toBe('ab'); | ||
}); | ||
|
||
act(() => { | ||
(map.click as any)({ | ||
target: { classList: { contains: (cls: string) => cls === 'gio-cascader-dropdown' } }, | ||
}); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(document.querySelectorAll('.gio-dropdown-hidden')).toHaveLength(1); | ||
}); | ||
}); | ||
|
||
it('should render sub-menu', async () => { | ||
const dataSource = [{ label: 'a', value: 1, children: [] as NodeData[] }]; | ||
const wrapper = mount(<Cascader dataSource={dataSource} visible trigger="click" />); | ||
|
||
act(() => { | ||
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').simulate('click', { currentTarget: {} }); | ||
}); | ||
await waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toEqual('a'); | ||
}); | ||
|
||
wrapper.setProps({ dataSource: [{ label: 'a', value: 1, children: [{ label: 'b', value: 2 }] }] }); | ||
act(() => { | ||
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(0).simulate('click', { currentTarget: {} }); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toEqual('ab'); | ||
}); | ||
}); | ||
|
||
it('should select a value', async () => { | ||
const onSelect = jest.fn(); | ||
const wrapper = mount(<Cascader visible dataSource={menu} onSelect={onSelect} trigger="click" />); | ||
|
||
act(() => { | ||
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(0).simulate('click', { currentTarget: {} }); | ||
}); | ||
|
||
await waitFor(() => { | ||
expect(onSelect).toBeCalled(); | ||
}); | ||
|
||
wrapper.setProps({ selectAny: true }); | ||
act(() => { | ||
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(1).simulate('keyup', { key: 'Enter' }); | ||
}); | ||
await waitFor(() => { | ||
expect((wrapper.find('.gio-cascader-title input').getDOMNode() as HTMLInputElement).value).toEqual('b'); | ||
}); | ||
}); | ||
|
||
it('can trigger a sub-menu', () => { | ||
const wrapper = mount(<Cascader visible dataSource={menu} trigger="click" />); | ||
|
||
act(() => { | ||
wrapper | ||
.find('.cascader-menu-item .cascader-menu-item-inner') | ||
.at(1) | ||
.simulate('click', { currentTarget: { offsetLeft: 0, offsetTop: 0 } }); | ||
}); | ||
waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toBe('abb-1'); | ||
}); | ||
}); | ||
|
||
it('can use keyboard to trigger sub-menu', () => { | ||
const wrapper = mount(<Cascader visible dataSource={menu} />); | ||
|
||
act(() => { | ||
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(1).simulate('keydown', { key: ' ' }); | ||
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(1).simulate('keyup', { key: ' ' }); | ||
}); | ||
waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toBe('abb-1'); | ||
}); | ||
}); | ||
|
||
it('can deep search a word', async () => { | ||
const dataSource = [{ label: 'foo', value: 1, children: [{ label: 'bar', value: 2 }] }]; | ||
const wrapper = mount(<Cascader visible deepSearch dataSource={dataSource} trigger="click" />); | ||
|
||
expect(wrapper.find('.cascader-menu-list').text()).toEqual('foo'); | ||
|
||
act(() => { | ||
wrapper.find('.cascader-menu-header input').simulate('change', { target: { value: 'f' } }); | ||
}); | ||
await waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toEqual('foo'); | ||
}); | ||
|
||
// deep search childNode | ||
act(() => { | ||
wrapper.find('.cascader-menu-header input').simulate('change', { target: { value: 'b' } }); | ||
}); | ||
await waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toEqual('foo'); | ||
}); | ||
|
||
// open a sub-menu | ||
act(() => { | ||
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(0).simulate('click', { currentTarget: {} }); | ||
}); | ||
await waitFor(() => { | ||
expect(wrapper.find('.cascader-menu-list').text()).toEqual('foobar'); | ||
}); | ||
}); | ||
|
||
it('can triggered by mouseEnter', async () => { | ||
const wrapper = mount(<Cascader trigger="hover" dataSource={menu} visible />); | ||
|
||
act(() => { | ||
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(1).simulate('mouseEnter', { currentTarget: {} }); | ||
}); | ||
|
||
await waitFor(() => { | ||
// expect(wrapper.find('.cascader-menu-item')).toHaveLength(3); | ||
expect(wrapper.find('.cascader-menu-list').text()).toEqual('abb-1'); | ||
}); | ||
}); | ||
|
||
it('can render menu-item by user', () => { | ||
const wrapper = mount( | ||
<Cascader dataSource={menu} visible onRender={(t: NodeData) => <span className="custom-item">{t.label}</span>} /> | ||
); | ||
|
||
expect(wrapper.find('.custom-item')).toHaveLength(2); | ||
}); | ||
}); |
23 changes: 23 additions & 0 deletions
23
packages/components/src/components/cascader/__test__/helper.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { NodeData } from '../menu-item'; | ||
import { isHit, dataFilter } from '../helper'; | ||
|
||
describe('isHit', () => { | ||
it('should match word', () => { | ||
const label = 'hello test'; | ||
expect(isHit(label, '', false)).toBeTruthy(); | ||
expect(isHit(label, 'hello', false)).toBeTruthy(); | ||
expect(isHit(label, 'xml', false)).toBeFalsy(); | ||
}); | ||
}); | ||
|
||
describe('dataFilter', () => { | ||
it('should match word by a parttern', () => { | ||
const data = [ | ||
{ label: 'hello', value: 1 }, | ||
{ label: 'nihao', value: 2, children: [] as NodeData[] }, | ||
]; | ||
expect(dataFilter(data, (null as unknown) as RegExp, true)).toHaveLength(2); | ||
expect(dataFilter(data, /h/i, true)).toHaveLength(2); | ||
expect(dataFilter(data, /hello/i, true)).toHaveLength(1); | ||
}); | ||
}); |
21 changes: 21 additions & 0 deletions
21
packages/components/src/components/cascader/__test__/menu.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { mount } from 'enzyme'; | ||
import React from 'react'; | ||
|
||
import { NodeData } from '../menu-item'; | ||
import Menu from '../menu'; | ||
|
||
describe('<Menu />', () => { | ||
it('should render menu-item', async () => { | ||
const menu = [{ label: 'a', value: 1 }]; | ||
const wrapper = mount(<Menu dataSource={menu} />); | ||
|
||
expect(wrapper.find('.cascader-menu .cascader-menu-item')).toHaveLength(1); | ||
}); | ||
|
||
it('should ignore empty node data', async () => { | ||
const menu = [] as NodeData[]; | ||
const wrapper = mount(<Menu depth={1} dataSource={menu} />); | ||
|
||
expect(wrapper.find('.cascader-menu')).toHaveLength(0); | ||
}); | ||
}); |
38 changes: 38 additions & 0 deletions
38
packages/components/src/components/cascader/__test__/search-bar.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { mount } from 'enzyme'; | ||
import React from 'react'; | ||
import { act } from 'react-dom/test-utils'; | ||
import SearchBar from '../search-bar'; | ||
|
||
describe('<SearchBar />', () => { | ||
it('can input', async () => { | ||
const wrapper = mount(<SearchBar />); | ||
act(() => { | ||
wrapper.find('input').simulate('change', { target: { value: '123' } }); | ||
}); | ||
|
||
expect((wrapper.find('input').getDOMNode() as HTMLInputElement).value).toBe('123'); | ||
|
||
wrapper.setProps({ lazySearch: true }); | ||
|
||
act(() => { | ||
wrapper.find('input').simulate('change', { target: { value: '234' } }); | ||
wrapper.find('input').simulate('keyup', { key: 'Enter' }); | ||
}); | ||
expect((wrapper.find('input').getDOMNode() as HTMLInputElement).value).toBe('234'); | ||
}); | ||
|
||
it('should be lazy change', () => { | ||
const fn = jest.fn(); | ||
const wrapper = mount(<SearchBar lazySearch onSearch={fn} />); | ||
|
||
act(() => { | ||
wrapper.find('input').simulate('change', { target: { value: '1' } }); | ||
}); | ||
expect(fn).not.toBeCalled(); | ||
|
||
act(() => { | ||
wrapper.find('input').simulate('keyup', { key: 'Enter' }); | ||
}); | ||
expect(fn).toBeCalled(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import { useEffect, useState } from 'react'; | ||
import isEmpty from 'lodash/isEmpty'; | ||
|
||
import { NodeData } from './menu-item'; | ||
|
||
export const withPrefix = (prefix?: string) => (value?: string, sep = '-') => { | ||
return [prefix, value].filter((s) => !!s).join(sep); | ||
}; | ||
|
||
export const makeSearchParttern = (word = '', ignoreCase = true) => { | ||
// 过滤掉正则表达式的特殊字符 | ||
const escapedWord = word.replace(/([()[\]{}.?+*^$|\\<>!])/g, '\\$1'); | ||
return new RegExp(escapedWord, ignoreCase ? 'gi' : 'g'); | ||
}; | ||
|
||
export const isHit = (label: string, word: string, ignoreCase = true) => { | ||
if (!word) { | ||
return true; | ||
} | ||
return label.search(makeSearchParttern(word, ignoreCase)) >= 0; | ||
}; | ||
|
||
export const useDynamicData = <T>(originDataSource: T) => { | ||
const [dataSource, setDataSource] = useState(originDataSource); | ||
|
||
useEffect(() => { | ||
setDataSource(originDataSource); | ||
}, [originDataSource]); | ||
|
||
return [dataSource, setDataSource] as const; | ||
}; | ||
|
||
export const toInt = (s: string | React.ReactText) => (s ? parseInt(s as string, 10) : 0); | ||
|
||
export const deepFilter = (d: NodeData, parttern: RegExp) => { | ||
const currentMatch = d.label.match(parttern); | ||
if (currentMatch) { | ||
return true; | ||
} | ||
if (isEmpty(d.children)) { | ||
return false; | ||
} | ||
|
||
const someChildMatch = d.children?.some((c) => deepFilter(c, parttern)) as boolean; | ||
|
||
return someChildMatch; | ||
}; | ||
|
||
export const dataFilter = (data: NodeData[], parttern: RegExp, deepSearch = false) => { | ||
if (!parttern) { | ||
return data; | ||
} | ||
|
||
if (!deepSearch) { | ||
return data.filter((d) => d.label.match(parttern)); | ||
} | ||
|
||
return data.filter((d) => deepFilter(d, parttern)); | ||
}; |
Oops, something went wrong.
bf59a0e
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Successfully deployed to the following URLs: