Skip to content

Commit

Permalink
feat(AutoComplete): add AutoComplete
Browse files Browse the repository at this point in the history
  • Loading branch information
ZxBing0066 committed Mar 22, 2021
1 parent a295b91 commit 41350a4
Show file tree
Hide file tree
Showing 25 changed files with 3,557 additions and 10 deletions.
3 changes: 3 additions & 0 deletions .styleguide/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ module.exports = [
{
name: 'Textarea'
},
{
name: 'AutoComplete'
},
{
name: 'Select',
components: ['Select', 'Option', 'Extra', 'Group']
Expand Down
59 changes: 59 additions & 0 deletions src/components/AutoComplete/AutoComplete.md
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"}
```
152 changes: 152 additions & 0 deletions src/components/AutoComplete/AutoComplete.tsx
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);
98 changes: 98 additions & 0 deletions src/components/AutoComplete/Popup.tsx
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);
69 changes: 69 additions & 0 deletions src/components/AutoComplete/__demo__/autoComplete.jsx
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;
Loading

0 comments on commit 41350a4

Please sign in to comment.