W-Design组件库:AutoComplete组件

214 阅读5分钟

组件效果

image.pnginput组件基础上扩展下拉框数据展示,支持自定义筛选数据fetchSuggestions方法,自定义渲染样式renderOption方法、选中某一项数据回调onSelect

组件结构

src/components/AutoComplete/autoComplete.tsx

  1. 下拉框使用Transition组件包裹,使有过渡动画,当搜索框关闭时,将列表设置为空数组
  2. 使用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')
  })
})