-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(AutoComplete): add AutoComplete
- Loading branch information
1 parent
a295b91
commit 41350a4
Showing
25 changed files
with
3,557 additions
and
10 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
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 @@ | ||
### 说明 | ||
|
||
- AutoComplete 能够尝试猜测⽤户输⼊的⽂字,并且动态的搜索出适配的结果并推荐给⽤户,辅助完成输⼊。⽬的是避免⽤户出错,起到提示作⽤,智能预测⽤户⼼理期望,从⽽确保他们的输⼊更有效率。 | ||
- 默认的弹出层容器为 forwardPopupContainer={triggerNode => triggerNode.parentNode},默认会查找上层的建议容器 | ||
|
||
### 数据结构 | ||
|
||
#### Key | ||
|
||
```ts {"static": true} | ||
interface Item { | ||
// 项的值 | ||
value: string; | ||
// 项的展示,为空时展示 value | ||
label?: ReactNode; | ||
} | ||
``` | ||
|
||
### 演示 | ||
|
||
- 普通使用 | ||
|
||
```js {"codepath": "autoComplete.jsx"} | ||
``` | ||
|
||
- disabled - 禁用 | ||
|
||
```js {"codepath": "disabled.jsx"} | ||
``` | ||
|
||
- options - 选项展示 | ||
|
||
```js {"codepath": "options.jsx"} | ||
``` | ||
|
||
- handleSearch - 自定义搜索 | ||
|
||
```js {"codepath": "handleSearch.jsx"} | ||
``` | ||
|
||
- controlled - 受控 | ||
|
||
```js {"codepath": "controlled.jsx"} | ||
``` | ||
|
||
- 动态加载数据 | ||
|
||
```js {"codepath": "loading.jsx"} | ||
``` | ||
|
||
- 输入建议 | ||
|
||
```js {"codepath": "suggest.jsx"} | ||
``` | ||
|
||
- popupContainer - 容器测试 | ||
|
||
```js {"codepath": "popupContainer.jsx"} | ||
``` |
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,152 @@ | ||
import React, { ChangeEvent, memo, ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'; | ||
|
||
import Input from 'src/components/Input'; | ||
import Popover from 'src/components/Popover'; | ||
import SvgIcon from 'src/components/SvgIcon'; | ||
import useUncontrolled from 'src/hooks/useUncontrolled'; | ||
import KeyCode from 'src/interfaces/KeyCode'; | ||
import ConfigContext from 'src/components/ConfigProvider/ConfigContext'; | ||
import Popup, { ListRef } from './Popup'; | ||
import { inputCls, loadingIconCls, SWrap } from './style'; | ||
|
||
interface Item { | ||
// 项的值 | ||
value: string; | ||
// 项的展示,为空时展示 value | ||
label?: ReactNode; | ||
} | ||
interface AutoCompleteProps { | ||
/** | ||
* 待筛选选项 | ||
*/ | ||
options: Item[]; | ||
/** | ||
* 值,controlled | ||
*/ | ||
value?: string; | ||
/** | ||
* 默认值 | ||
*/ | ||
defaultValue?: string; | ||
/** | ||
* 选中回调 | ||
*/ | ||
onChange?: (v: string) => void; | ||
/** | ||
* 是否禁用 | ||
*/ | ||
disabled?: boolean; | ||
/** | ||
* options 加载中状态 | ||
*/ | ||
optionsLoading?: boolean; | ||
/** | ||
* 未输入内容时是否展示建议选项 | ||
*/ | ||
displayOptionsWhenEmpty?: boolean; | ||
/** | ||
* 自定义搜索,为 false 时不做搜索展示全部 | ||
*/ | ||
handleSearch?: false | ((v: Item) => boolean); | ||
/** | ||
* 自定义 popover 的配置 | ||
*/ | ||
popoverProps?: { [key: string]: any }; | ||
} | ||
|
||
const AutoComplete = ({ | ||
value: _value, | ||
defaultValue = '', | ||
onChange: _onChange, | ||
options = [], | ||
disabled, | ||
optionsLoading: loading, | ||
displayOptionsWhenEmpty, | ||
handleSearch, | ||
popoverProps | ||
}: AutoCompleteProps) => { | ||
const [value, onChange] = useUncontrolled<string>(_value, defaultValue, _onChange); | ||
const [visible, setVisible] = useState(false); | ||
const list = useRef<ListRef>(null); | ||
const onInputChange = useCallback( | ||
(e: ChangeEvent<HTMLInputElement>) => { | ||
onChange(e.target.value); | ||
setVisible(true); | ||
}, | ||
[onChange] | ||
); | ||
const onInputFocus = useCallback(() => setVisible(true), []); | ||
const onInputBlur = useCallback(() => setVisible(false), []); | ||
const onInputClick = useCallback(() => setVisible(true), []); | ||
const onKeyPress = useCallback((e: KeyboardEvent) => { | ||
let intercept = true; | ||
switch (e.keyCode) { | ||
case KeyCode.ARROW_UP: | ||
list && list.current && list.current.moveUp(); | ||
break; | ||
case KeyCode.ARROW_DOWN: | ||
list && list.current && list.current.moveDown(); | ||
break; | ||
case KeyCode.ENTER: | ||
list && list.current && list.current.select(); | ||
break; | ||
default: | ||
intercept = false; | ||
break; | ||
} | ||
if (intercept) { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
} | ||
}, []); | ||
const onSelect = useCallback( | ||
(v: string) => { | ||
onChange(v); | ||
setVisible(false); | ||
}, | ||
[onChange] | ||
); | ||
const configContext = useContext(ConfigContext); | ||
const popoverContainerProps = useMemo(() => { | ||
return { | ||
...(configContext.forwardPopupContainer | ||
? { forwardPopupContainer: (triggerNode: HTMLElement) => triggerNode.parentNode } | ||
: { getPopupContainer: (triggerNode: HTMLElement) => triggerNode.parentNode }) | ||
}; | ||
}, [configContext.forwardPopupContainer]); | ||
|
||
return ( | ||
<SWrap> | ||
<Popover | ||
popup={ | ||
<Popup | ||
searchValue={value} | ||
options={options} | ||
handleSearch={handleSearch} | ||
onChange={onSelect} | ||
loading={loading} | ||
/> | ||
} | ||
trigger={[]} | ||
visible={!!(value || displayOptionsWhenEmpty) && visible} | ||
stretch={['minWidth']} | ||
{...popoverContainerProps} | ||
{...popoverProps} | ||
> | ||
<Input | ||
value={value} | ||
onChange={onInputChange} | ||
onFocus={onInputFocus} | ||
onBlur={onInputBlur} | ||
onClick={onInputClick} | ||
onKeyPress={onKeyPress} | ||
disabled={disabled} | ||
suffix={loading && <SvgIcon className={loadingIconCls} type="ring-loading" spin />} | ||
className={inputCls} | ||
/> | ||
</Popover> | ||
</SWrap> | ||
); | ||
}; | ||
|
||
export default memo(AutoComplete); |
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,98 @@ | ||
import React, { ReactNode, Ref, useCallback, useImperativeHandle, useMemo, useState } from 'react'; | ||
|
||
import Menu, { Item as MenuItem } from 'src/components/Menu'; | ||
import Loading from 'src/components/Loading'; | ||
import { menuCls, PopupWrap } from './style'; | ||
|
||
interface Item { | ||
value: string; | ||
label?: ReactNode; | ||
} | ||
|
||
interface PopupProps { | ||
options: Item[]; | ||
searchValue: string; | ||
onChange: (v: string) => void; | ||
handleSearch?: false | ((item: Item) => boolean); | ||
loading?: boolean; | ||
} | ||
|
||
export interface ListRef { | ||
moveUp: () => void; | ||
moveDown: () => void; | ||
select: () => void; | ||
} | ||
|
||
const defaultSearch = (item: Item, searchValue: string) => item.value.indexOf(searchValue) >= 0; | ||
|
||
const PopupWithOutMemo = React.forwardRef(function Popup( | ||
{ searchValue, options, onChange, handleSearch, loading }: PopupProps, | ||
ref: Ref<ListRef> | ||
) { | ||
const searchResult = useMemo(() => { | ||
if (handleSearch === false) return options || []; | ||
const filterHandle = typeof handleSearch === 'function' ? handleSearch : defaultSearch; | ||
const result = options.filter(item => filterHandle(item, searchValue)); | ||
return result; | ||
}, [handleSearch, options, searchValue]); | ||
|
||
const [index, setIndex] = useState<number | null>(null); | ||
useImperativeHandle( | ||
ref, | ||
() => { | ||
return { | ||
moveUp: () => { | ||
if (index === null) { | ||
setIndex(searchResult.length - 1); | ||
} else { | ||
setIndex((index + 1) % searchResult.length); | ||
} | ||
}, | ||
moveDown: () => { | ||
if (index === null) { | ||
setIndex(0); | ||
} else { | ||
setIndex((index + 1) % searchResult.length); | ||
} | ||
}, | ||
select: () => { | ||
if (index === null) return; | ||
onChange(searchResult[index].value); | ||
} | ||
}; | ||
}, | ||
[index, searchResult, onChange] | ||
); | ||
|
||
const onSelect = useCallback( | ||
values => { | ||
onChange(values[0]); | ||
}, | ||
[onChange] | ||
); | ||
const onMouseDown = useCallback((e: MouseEvent) => { | ||
e.preventDefault(); | ||
}, []); | ||
|
||
if (!searchResult.length) return null; | ||
|
||
return ( | ||
<PopupWrap loading={loading} indicator={null}> | ||
<Menu | ||
selectedKeys={[searchValue]} | ||
onChange={onSelect} | ||
onMouseDown={onMouseDown} | ||
customStyle={{ maxHeight: '182px', maxWidth: '800px' }} | ||
className={menuCls} | ||
> | ||
{searchResult.map(item => ( | ||
<MenuItem itemKey={item.value} key={item.value}> | ||
{item.label || item.value} | ||
</MenuItem> | ||
))} | ||
</Menu> | ||
</PopupWrap> | ||
); | ||
}); | ||
|
||
export default React.memo(PopupWithOutMemo); |
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,69 @@ | ||
import React from 'react'; | ||
|
||
import demoUtil from 'tests/shared/demoUtil'; | ||
import AutoComplete from 'src/components/AutoComplete'; | ||
import Form from 'src/components/Form'; | ||
import Radio from 'src/components/Radio'; | ||
import Switch from 'src/components/Switch'; | ||
|
||
// demo start | ||
const { formLayout, DemoWrap } = demoUtil; | ||
const options = new Array(100).fill(null).map((v, i) => ({ value: `Item ${i}` })); | ||
class Demo extends React.PureComponent { | ||
constructor(props) { | ||
super(props); | ||
this.state = { | ||
handleSearch: 'default', | ||
disabled: false | ||
}; | ||
} | ||
render() { | ||
const { handleSearch, disabled, displayOptionsWhenEmpty, optionsLoading } = this.state; | ||
const props = { | ||
disabled, | ||
displayOptionsWhenEmpty, | ||
optionsLoading | ||
}; | ||
if (handleSearch === 'false') { | ||
props.handleSearch = false; | ||
} else if (handleSearch === 'custom') { | ||
props.handleSearch = (item, searchValue) => { | ||
return item.value.toUpperCase().indexOf(searchValue.toUpperCase()) >= 0; | ||
}; | ||
} | ||
return ( | ||
<div> | ||
<Form className="demo-form" itemProps={{ ...formLayout }}> | ||
<Form.Item label="disabled"> | ||
<Switch checked={disabled} onChange={disabled => this.setState({ disabled })} /> | ||
</Form.Item> | ||
<Form.Item label="displayOptionsWhenEmpty"> | ||
<Switch | ||
checked={displayOptionsWhenEmpty} | ||
onChange={displayOptionsWhenEmpty => this.setState({ displayOptionsWhenEmpty })} | ||
/> | ||
</Form.Item> | ||
<Form.Item label="optionsLoading"> | ||
<Switch | ||
checked={optionsLoading} | ||
onChange={optionsLoading => this.setState({ optionsLoading })} | ||
/> | ||
</Form.Item> | ||
<Form.Item label="handleSearch"> | ||
<Radio.Group | ||
value={handleSearch} | ||
onChange={handleSearch => this.setState({ handleSearch })} | ||
options={['default', 'false', 'custom'].map(v => ({ value: v }))} | ||
/> | ||
</Form.Item> | ||
</Form> | ||
<DemoWrap> | ||
<AutoComplete options={options} onChange={console.log} {...props} /> | ||
</DemoWrap> | ||
</div> | ||
); | ||
} | ||
} | ||
// demo end | ||
|
||
export default Demo; |
Oops, something went wrong.