Shadcn Component:AutoComplete
2 min read
目录

需求

  1. 一个输入框输入文本内容,比如地址信息,活动信息等记录相关的字符串。
  2. 当输入框聚焦的时候,如果存在记录就以下拉框的形式展示自动补待选项列表。
  3. 如果点击某个待选项,输入框内容替换为对应点击的待选项展示的字符串。
  4. 如果输入的字符串与某个待选项相同,那么自动勾选该待选项。
  5. 还需要考虑配合 FormLabel 组件的校验联动。

分析

  1. 本质还是一个输入框,状态管理主要在于输入框,下拉列表仅仅辅助输入填充文本。
  2. Input 聚焦时打开 PopoverContent
  3. PopoverContent 待选项点击时,将待选项填充到 Input 然后关闭。

思路

Popover + Input 实现,注意要点:

  1. 输入聚焦时展开 popover;
  2. 展开 popover 的时候需要保持 input 聚焦,需要在 onOpenAutoFocus 中阻止默认事件,该事件会导致当鼠标点击输入框的时候,自动聚焦到 PopoverContent。
  3. 用户体验优化:键盘操作 Tab 切换聚焦组件, Enter 补全选中的选项。
  4. 考虑结合 form 使用时候的校验 onBlur 触发时机。

代码实现

import { cn } from "@mono/utils"
import { CheckIcon } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  Input,
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "../../ui"

interface AutoCompleteProps {
  /** 输入的字符串, 也用于搜索 */
  value?: string
  /** 输入和搜索回调 */
  onChange: (value: string) => void
  /** 下拉选项 */
  options: string[]
  /** 失焦回调 */
  onBlur?: () => void
}

export function AutoComplete({
  options,
  value,
  onChange,
  onBlur,
}: AutoCompleteProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const popoverContentRef = useRef<HTMLDivElement>(null)
  const [open, setOpen] = useState(false)
  const [selected, setSelected] = useState("")

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Tab" && open) {
        e.preventDefault()
        popoverContentRef.current?.focus()
      }

      if (e.key === "Enter" && open) {
        e.preventDefault()
        setOpen(false)
      }
    }

    const inputElement = inputRef.current
    if (inputElement) {
      inputElement.addEventListener("keydown", handleKeyDown)
    }

    return () => {
      if (inputElement) {
        inputElement.removeEventListener("keydown", handleKeyDown)
      }
    }
  }, [open])

  return (
    <Popover
      open={open}
      onOpenChange={(open) => {
        setOpen(open)
        if (!open) {
          onBlur?.()
        }
      }}
    >
      <PopoverTrigger className="block w-full">
        <Input
          ref={inputRef}
          value={value}
          onChange={(e) => {
            onChange(e.target.value)
            setSelected(e.target.value)
          }}
        />
      </PopoverTrigger>
      <PopoverContent
        fill
        onOpenAutoFocus={(e) => {
          e.preventDefault()
        }}
        onFocusOutside={() => {
          setOpen(false)
          onBlur?.()
        }}
        className={cn("p-0", !options.length && "transparent")}
      >
        <Command ref={popoverContentRef}>
          <div className="hidden">
            <CommandInput value={value} />
          </div>
          <CommandList>
            <CommandEmpty className="hidden">{"No results"}</CommandEmpty>
            <CommandGroup>
              {options.map((option) => {
                const isSelected = selected === option
                return (
                  <CommandItem
                    className="capitalize"
                    disabled={false}
                    key={option}
                    onSelect={() => {
                      onChange(option)
                      setSelected(option)
                      setOpen(false)
                      onBlur?.()
                    }}
                  >
                    <div
                      className={cn(
                        "mr-2 flex h-4 w-4 items-center justify-center",
                        isSelected
                          ? "opacity-100 [&_svg]:text-primary"
                          : "opacity-50 [&_svg]:invisible"
                      )}
                    >
                      <CheckIcon className="h-4 w-4" />
                    </div>
                    <span className={cn(isSelected && "text-primary")}>
                      {option}
                    </span>
                  </CommandItem>
                )
              })}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

效果

总结

在 shadcn 复制的代码片段中,关于 radix 的 api 使用并不全面,需要熟悉 Popover – Radix Primitives 文档中的源语使用场景和方法(onOpenAutoFocus、onFocusOutside),才能更便捷的实现需求。