需求
- 一个输入框输入文本内容,比如地址信息,活动信息等记录相关的字符串。
- 当输入框聚焦的时候,如果存在记录就以下拉框的形式展示自动补待选项列表。
- 如果点击某个待选项,输入框内容替换为对应点击的待选项展示的字符串。
- 如果输入的字符串与某个待选项相同,那么自动勾选该待选项。
- 还需要考虑配合
FormLabel
组件的校验联动。
分析
- 本质还是一个输入框,状态管理主要在于输入框,下拉列表仅仅辅助输入填充文本。
- 当
Input
聚焦时打开PopoverContent
。 - 当
PopoverContent
待选项点击时,将待选项填充到Input
然后关闭。
思路
用 Popover
+ Input
实现,注意要点:
- 输入聚焦时展开 popover;
- 展开 popover 的时候需要保持 input 聚焦,需要在
onOpenAutoFocus
中阻止默认事件,该事件会导致当鼠标点击输入框的时候,自动聚焦到 PopoverContent。 - 用户体验优化:键盘操作
Tab
切换聚焦组件,Enter
补全选中的选项。 - 考虑结合 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),才能更便捷的实现需求。