组件效果
在
input组件基础上扩展下拉框数据展示,支持自定义筛选数据fetchSuggestions方法,自定义渲染样式renderOption方法、选中某一项数据回调onSelect
组件结构
src/components/AutoComplete/autoComplete.tsx
- 下拉框使用
Transition组件包裹,使有过渡动画,当搜索框关闭时,将列表设置为空数组 - 使用useCallback包裹防抖函数,是因为更新数据时防抖函数会重新生成新的函数
import {
FC,
KeyboardEvent,
ReactElement,
useCallback,
useRef,
useState,
} from 'react'
import Input, { InputProps } from '../Input/input'
import { Icon } from '../Icon'
import { debounce } from 'lodash'
import classNames from 'classnames'
import useClickOutside from '../../hooks/useClickOutside'
import { Transition } from '../Transition'
interface DataSourceObject {
value: string
}
export type DataSourceType<T = {}> = T & DataSourceObject
export interface AutoCompleteProps extends Omit<InputProps, 'onSelect'> {
/** 返回推荐结果 */
fetchSuggestions: (
str: string
) => DataSourceType[] | Promise<DataSourceType[]>
/** 选择选中某一项 */
onSelect?: (item: DataSourceType) => void
/** 自定义渲染样式 */
renderOption?: (item: DataSourceType) => ReactElement
}
export const AutoComplete: FC<AutoCompleteProps> = (props) => {
const { fetchSuggestions, onSelect, value, renderOption, ...restProps } =
props
// 输入的值
const [inputValue, setInputValue] = useState(value)
// 展示的数据
const [suggestions, setSuggestions] = useState<DataSourceType[]>([])
// 数据加载loading
const [loading, setLoading] = useState(false)
// 当前高亮的索引,如果为0的话会高亮第一个选项
const [highlightIndex, setHighlightIndex] = useState(-1)
// 控制Transition
const [showDropdown, setShowDropdown] = useState(false)
const componentRef = useRef<HTMLDivElement>(null)
// 处理点击组件外地方可以隐藏下拉框
useClickOutside(componentRef, () => {
setSuggestions([])
})
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim()
setInputValue(value)
debounceFn(value)
}
// 处理数据
const handleDebounceFn = (value: string) => {
setSuggestions([])
if (value) {
const results = fetchSuggestions(value)
setLoading(true)
if (results instanceof Promise) {
results.then((data) => {
setLoading(false)
setSuggestions(data)
if (data.length > 0) {
setShowDropdown(true)
}
})
} else {
setSuggestions(results)
setShowDropdown(true)
setLoading(false)
}
} else {
setShowDropdown(false)
setSuggestions([])
}
// 每次搜索完value都需要重置highlightIndex,否则多次搜索后高亮还会存在
setHighlightIndex(-1)
}
// 使用useCallback是因为debounceFn函数在每次被调用时会重新创建
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceFn = useCallback(debounce(handleDebounceFn, 500), [])
// 处理回车或者选择某一项
const handleSelect = (item: DataSourceType) => {
setInputValue(item.value)
setShowDropdown(false)
setSuggestions([])
onSelect && onSelect(item)
}
// 处理向上
const highlight = (index: number) => {
// 如果索引小于0,则将索引设置为0
if (index < 0) index = 0
// 如果索引大于等于列表的长度,则将索引设置为列表的长度减1
if (index >= suggestions.length) {
index = suggestions.length - 1
}
setHighlightIndex(index)
}
// 处理键盘事件
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.keyCode) {
case 13: // enter
if (suggestions[highlightIndex]) {
// 因为回车是回显数据,所以调用handleSelect传入highlightIndex对应的suggestions值
handleSelect(suggestions[highlightIndex])
}
break
case 38: // up
highlight(highlightIndex - 1)
break
case 40: // down
highlight(highlightIndex + 1)
break
case 27: // esc
setShowDropdown(false)
// esc是关闭下拉框,将suggestions重置为空数组就会不渲染
setSuggestions([])
break
default:
break
}
}
// 渲染下拉选项
const renderTemplate = (item: DataSourceType) => {
return renderOption ? renderOption(item) : item.value
}
// 数据下拉框
const generateDropdown = () => {
return (
<Transition
in={showDropdown || loading}
animation="zoom-in-top"
timeout={300}
onExited={() => {
setSuggestions([])
}}
>
<ul className="suggestion-list">
{loading && (
<div className="suggestions-loading-icon">
<Icon icon="spinner" spin />
</div>
)}
{suggestions.map((item, index) => {
const classnames = classNames('suggestion-item', {
'is-active': index === highlightIndex,
})
return (
<li
key={index}
className={classnames}
onClick={() => handleSelect(item)}
>
{renderTemplate(item)}
</li>
)
})}
</ul>
</Transition>
)
}
return (
<div className="auto-complete" ref={componentRef}>
<Input
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...restProps}
/>
{(suggestions.length > 0 || loading) && generateDropdown()}
</div>
)
}
src/hooks/useClickOutside.tsx
ref.current.contains会判断点击时的元素是否是AutoComplete组件的子元素,如果不是就隐藏下拉框
import { RefObject, useEffect } from 'react'
export default function useClickOutside(
ref: RefObject<HTMLDivElement>,
handle: Function
) {
useEffect(() => {
const listener = (event: MouseEvent) => {
// 如果当前引用不存在或者不是ref的子节点,则返回
if (!ref.current || ref.current.contains(event.target as Node)) {
return
}
handle()
}
document.addEventListener('click', listener)
return () => {
document.removeEventListener('click', listener)
}
}, [ref, handle])
}
组件样式
src/components/AutoComplete/_.style.scss
.auto-complete {
position: relative;
}
.suggestion-list {
// 设置列表的样式为none
list-style: none;
// 设置列表项的padding
padding-left: 0;
// 设置列表项的white-space
white-space: nowrap;
// 设置列表项的位置
position: absolute;
// 设置列表的背景色
background: $white;
// 设置列表的层级
z-index: 100;
// 设置列表的top值
top: calc(100% + 8px);
// 设置列表的left值
left: 0;
// 设置列表的边框
border: $menu-border-width solid $menu-border-color;
// 设置列表的阴影
box-shadow: $submenu-box-shadow;
// 设置列表的宽度
width: 100%;
// 设置列表的margin-top
margin-top: 0;
// 设置加载图标
.suggestions-loading-icon {
// 设置加载图标的样式
display: flex;
align-items: center;
justify-content: center;
min-height: 75px;
}
// 设置列表项
.suggestion-item {
// 设置列表项的padding
padding: $menu-item-padding-y $menu-item-padding-x;
// 设置列表项的鼠标样式
cursor: pointer;
// 设置列表项的过渡效果
transition: $menu-transition;
// 设置列表项的颜色
color: $body-color;
// 设置列表项的激活状态
&.is-active {
// 设置列表项的背景色
background: $menu-item-active-color !important;
// 设置列表项的颜色
color: $white !important;
}
// 设置列表项的hover效果
&:hover {
// 设置列表项的颜色
color: $menu-item-active-color !important;
}
}
}
组件测试
src/components/AutoComplete/autoComplete.test.tsx
/* eslint-disable testing-library/no-node-access */
/* eslint-disable testing-library/no-container */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AutoComplete, AutoCompleteProps } from './autoComplete'
const testArray = [
{ value: 'ab', number: 11 },
{ value: 'abc', number: 1 },
{ value: 'b', number: 4 },
{ value: 'c', number: 15 },
]
const testProps: AutoCompleteProps = {
fetchSuggestions: (query) => {
return testArray.filter((item) => item.value.includes(query))
},
onSelect: jest.fn(),
placeholder: 'auto-completed',
}
// 测试 AutoComplete 组件的基本行为
describe('test AutoComplete component', () => {
// 测试基本 AutoComplete 行为
it('test basic AutoComplete behavior', async () => {
// 渲染 AutoComplete 组件
const { container } = render(<AutoComplete {...testProps} />)
// 获取输入框节点
const inputNode = screen.getByPlaceholderText(
'auto-completed'
) as HTMLInputElement
// 修改输入框的值
fireEvent.change(inputNode, { target: { value: 'a' } })
// 等待文档中出现 ab
await waitFor(() => {
expect(screen.getByText('ab')).toBeInTheDocument()
})
// 期望文档中出现两个 suggestion-item
expect(container.querySelectorAll('.suggestion-item').length).toEqual(2)
// 点击 ab
fireEvent.click(screen.getByText('ab'))
// 期望 onSelect 函数被调用
expect(testProps.onSelect).toHaveBeenCalledWith({ value: 'ab', number: 11 })
// 期望文档中不出现 ab
expect(screen.queryByText('ab')).not.toBeInTheDocument()
// 期望输入框的值是 ab
expect(inputNode.value).toBe('ab')
})
// 测试键盘支持
it('should provide keyboard support', async () => {
// 渲染 AutoComplete 组件
render(<AutoComplete {...testProps} />)
// 获取输入框节点
const inputNode = screen.getByPlaceholderText(
'auto-completed'
) as HTMLInputElement
// 修改输入框的值
fireEvent.change(inputNode, { target: { value: 'a' } })
// 等待文档中出现 ab
await waitFor(() => {
expect(screen.getByText('ab')).toBeInTheDocument()
})
// 获取第一个结果和第二个结果
const firstResult = screen.queryByText('ab')
const secondResult = screen.queryByText('abc')
// 按下向下键
fireEvent.keyDown(inputNode, { keyCode: 40 })
// 期望第一个结果有 is-active 类
expect(firstResult).toHaveClass('is-active')
// 按下向下键
fireEvent.keyDown(inputNode, { keyCode: 40 })
// 期望第二个结果有 is-active 类
expect(secondResult).toHaveClass('is-active')
// 按下向上键
fireEvent.keyDown(inputNode, { keyCode: 38 })
// 期望第一个结果有 is-active 类
expect(firstResult).toHaveClass('is-active')
// 按下回车键
fireEvent.keyDown(inputNode, { keyCode: 13 })
// 期望 onSelect 函数被调用
expect(testProps.onSelect).toHaveBeenCalledWith({ value: 'ab', number: 11 })
// 期望输入框的值是 ab
expect(inputNode.value).toBe('ab')
// 期望文档中不出现 ab
expect(screen.queryByText('ab')).not.toBeInTheDocument()
})
// 测试点击文档外层时,是否隐藏下拉框
it('click outside should hide the dropdown', async () => {
// 渲染 AutoComplete 组件
render(<AutoComplete {...testProps} />)
// 获取输入框节点
const inputNode = screen.getByPlaceholderText(
'auto-completed'
) as HTMLInputElement
// 修改输入框的值
fireEvent.change(inputNode, { target: { value: 'a' } })
// 等待文档中出现 ab
await waitFor(() => {
expect(screen.getByText('ab')).toBeInTheDocument()
})
// 点击文档外层
fireEvent.click(document)
// 期望文档中不出现 ab
expect(screen.queryByText('ab')).not.toBeInTheDocument()
})
// 测试 renderOption 函数是否生成正确的模板
it('renderOption should generate the right template', async () => {
// 渲染 AutoComplete 组件
render(
<AutoComplete
{...testProps}
renderOption={(item) => {
return <h1>{item.value}</h1>
}}
/>
)
// 获取输入框节点
const inputNode = screen.getByPlaceholderText(
'auto-completed'
) as HTMLInputElement
// 修改输入框的值
fireEvent.change(inputNode, { target: { value: 'a' } })
// 等待文档中出现 ab
await waitFor(() => {
expect(screen.getByText('ab')).toBeInTheDocument()
})
// 期望文档中 ab 的标签名是 h1
expect(screen.getByText('ab').tagName).toEqual('H1')
})
})