Files
one-api-workers/frontend/src/components/ui/autocomplete.tsx
T

156 lines
4.4 KiB
TypeScript

import * as React from 'react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
export type AutoCompleteOption = {
value: string
label?: string
description?: string
keywords?: string[]
}
type AutoCompleteProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> & {
value: string
onChange: (value: string) => void
options: AutoCompleteOption[]
emptyText?: string
dropdownClassName?: string
inputClassName?: string
}
const normalizeText = (value: string): string => value.trim().toLowerCase()
const matchesOption = (option: AutoCompleteOption, query: string): boolean => {
if (!query) return true
const haystack = [
option.value,
option.label || '',
option.description || '',
...(option.keywords || []),
]
.join(' ')
.toLowerCase()
return haystack.includes(query)
}
export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteProps>(
(
{
value,
onChange,
options,
emptyText = '无匹配结果',
className,
dropdownClassName,
inputClassName,
onClick,
onFocus,
onBlur,
disabled,
...props
},
ref
) => {
const containerRef = React.useRef<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState('')
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
React.useEffect(() => {
if (!isOpen) {
setSearchQuery('')
}
}, [isOpen])
const query = normalizeText(searchQuery)
const dedupedOptions = options.filter((option, index) => {
return options.findIndex((candidate) => candidate.value === option.value) === index
})
const filteredOptions = dedupedOptions.filter((option) => matchesOption(option, query)).slice(0, 12)
const handleSelect = (selectedValue: string) => {
onChange(selectedValue)
setIsOpen(false)
}
return (
<div ref={containerRef} className={cn('relative', className)}>
<Input
{...props}
ref={ref}
disabled={disabled}
value={value}
onClick={(event) => {
setSearchQuery('')
setIsOpen(true)
onClick?.(event)
}}
onFocus={(event) => {
setSearchQuery('')
setIsOpen(true)
onFocus?.(event)
}}
onBlur={(event) => {
onBlur?.(event)
}}
onChange={(event) => {
onChange(event.target.value)
setSearchQuery(event.target.value)
setIsOpen(true)
}}
className={inputClassName}
/>
{isOpen && !disabled && (
<div
className={cn(
'absolute z-20 mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-lg',
dropdownClassName
)}
>
{filteredOptions.length > 0 ? (
<div className="max-h-72 overflow-y-auto py-1">
{filteredOptions.map((option) => {
const label = option.label || option.value
const showDescription = option.description && option.description !== label
return (
<button
key={option.value}
type="button"
className="flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left hover:bg-muted/80 transition-colors"
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelect(option.value)}
>
<span className="text-sm font-medium">{label}</span>
{showDescription && (
<span className="text-xs text-muted-foreground">{option.description}</span>
)}
</button>
)
})}
</div>
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">{emptyText}</div>
)}
</div>
)}
</div>
)
}
)
AutoCompleteInput.displayName = 'AutoCompleteInput'