'use client' import { useEffect, useId, useMemo, useRef, useState } from 'react' import { pinyin } from 'pinyin-pro' interface User { id: string username: string displayName: string } interface SearchableUserSelectProps { id?: string users: User[] value: string onChange: (userId: string, displayName: string) => void placeholder?: string required?: boolean className?: string disabled?: boolean clearable?: boolean loading?: boolean } export default function SearchableUserSelect({ id, users, value, onChange, placeholder = '请选择或搜索用户', required = false, className = '', disabled = false, clearable = true, loading = false, }: SearchableUserSelectProps) { const [searchTerm, setSearchTerm] = useState('') const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(0) const containerRef = useRef(null) const inputRef = useRef(null) const listboxId = useId() const isDisabled = disabled || loading // 获取选中用户的显示名称 const selectedUser = useMemo(() => users.find((user) => user.id === value), [users, value]) const displayValue = selectedUser ? selectedUser.displayName === selectedUser.username ? selectedUser.displayName : `${selectedUser.displayName} (${selectedUser.username})` : '' // 人员数量较多时,预先生成一次搜索索引,避免每次按键都重复计算全量拼音。 const indexedUsers = useMemo(() => users.map((user) => { const fullPinyin = pinyin(user.displayName, { toneType: 'none', type: 'array' }).join('') const firstLetters = pinyin(user.displayName, { pattern: 'first', toneType: 'none', type: 'array', }).join('') return { user, searchText: [ user.displayName, user.username, fullPinyin, firstLetters, ].join(' ').toLowerCase(), } }), [users]) const filteredUsers = useMemo(() => { const keyword = searchTerm.trim().toLowerCase() if (!keyword) return indexedUsers.map((item) => item.user) return indexedUsers .filter((item) => item.searchText.includes(keyword)) .map((item) => item.user) }, [indexedUsers, searchTerm]) useEffect(() => { setHighlightedIndex((current) => Math.min(current, Math.max(filteredUsers.length - 1, 0))) }, [filteredUsers.length]) // 点击外部关闭下拉框 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, []) // 键盘导航 const handleKeyDown = (e: React.KeyboardEvent) => { if (isDisabled) { return } if (!isOpen) { if (e.key === 'Enter' || e.key === 'ArrowDown') { e.preventDefault() setIsOpen(true) } return } switch (e.key) { case 'ArrowDown': e.preventDefault() setHighlightedIndex(prev => prev < filteredUsers.length - 1 ? prev + 1 : prev ) break case 'ArrowUp': e.preventDefault() setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0) break case 'Enter': e.preventDefault() if (filteredUsers[highlightedIndex]) { handleSelect(filteredUsers[highlightedIndex]) } break case 'Escape': e.preventDefault() setIsOpen(false) break } } // 选择用户 const handleSelect = (user: User) => { onChange(user.id, user.displayName) setSearchTerm('') setIsOpen(false) setHighlightedIndex(0) } // 打开下拉框 const handleFocus = () => { if (isDisabled) { return } setIsOpen(true) setHighlightedIndex(0) } // 清空选择 const handleClear = (e: React.MouseEvent) => { e.stopPropagation() if (isDisabled || !clearable) { return } onChange('', '') setSearchTerm('') setIsOpen(false) inputRef.current?.focus() } return (
setSearchTerm(e.target.value)} onFocus={handleFocus} onKeyDown={handleKeyDown} placeholder={loading ? '正在加载人员...' : placeholder} required={required} disabled={isDisabled} className="input-apple pr-20 disabled:cursor-not-allowed disabled:opacity-60" autoComplete="off" role="combobox" aria-autocomplete="list" aria-expanded={isOpen && !isDisabled} aria-controls={listboxId} aria-activedescendant={ isOpen && filteredUsers[highlightedIndex] ? `${listboxId}-option-${filteredUsers[highlightedIndex].id}` : undefined } aria-busy={loading} />
{value && clearable && !isDisabled && ( )}
{isOpen && !isDisabled && (
{filteredUsers.length === 0 ? (
未找到匹配的用户
) : ( filteredUsers.map((user, index) => ( )) )}
)}
) }