211 lines
7.8 KiB
TypeScript
211 lines
7.8 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||
import { pinyin } from 'pinyin-pro'
|
||
import type { WorkReviewReviewerOption } from '@/types/work-review'
|
||
|
||
export function WorkReviewReviewerSelect({
|
||
options,
|
||
value,
|
||
disabled,
|
||
loading,
|
||
onChange,
|
||
}: {
|
||
options: WorkReviewReviewerOption[]
|
||
value: string
|
||
disabled: boolean
|
||
loading: boolean
|
||
onChange: (employeeId: string) => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [query, setQuery] = useState('')
|
||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||
const listboxId = useId()
|
||
const selected = options.find((option) => option.id === value) || null
|
||
const isDisabled = disabled || loading
|
||
|
||
const indexedOptions = useMemo(() => options.map((option) => ({
|
||
option,
|
||
searchable: [
|
||
option.name,
|
||
option.jobNumber,
|
||
option.departmentName,
|
||
option.positionTitle,
|
||
pinyin(option.name, { toneType: 'none' }),
|
||
pinyin(option.name, { pattern: 'first', toneType: 'none' }),
|
||
].filter(Boolean).join(' ').toLowerCase(),
|
||
})), [options])
|
||
|
||
const filteredOptions = useMemo(() => {
|
||
const keyword = query.trim().toLowerCase()
|
||
if (!keyword) return indexedOptions.map((item) => item.option)
|
||
return indexedOptions
|
||
.filter((item) => item.searchable.includes(keyword))
|
||
.map((item) => item.option)
|
||
}, [indexedOptions, query])
|
||
|
||
useEffect(() => {
|
||
setHighlightedIndex((current) => Math.min(current, Math.max(0, filteredOptions.length - 1)))
|
||
}, [filteredOptions.length])
|
||
|
||
useEffect(() => {
|
||
const handleOutsideClick = (event: MouseEvent) => {
|
||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||
setOpen(false)
|
||
setQuery('')
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleOutsideClick)
|
||
return () => document.removeEventListener('mousedown', handleOutsideClick)
|
||
}, [])
|
||
|
||
const selectOption = (option: WorkReviewReviewerOption) => {
|
||
onChange(option.id)
|
||
setQuery('')
|
||
setOpen(false)
|
||
setHighlightedIndex(0)
|
||
}
|
||
|
||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (isDisabled) return
|
||
if (!open && (event.key === 'Enter' || event.key === 'ArrowDown')) {
|
||
event.preventDefault()
|
||
setOpen(true)
|
||
return
|
||
}
|
||
if (!open) return
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault()
|
||
setHighlightedIndex((current) => Math.min(current + 1, filteredOptions.length - 1))
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault()
|
||
setHighlightedIndex((current) => Math.max(current - 1, 0))
|
||
} else if (event.key === 'Enter' && filteredOptions[highlightedIndex]) {
|
||
event.preventDefault()
|
||
selectOption(filteredOptions[highlightedIndex])
|
||
} else if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
setOpen(false)
|
||
setQuery('')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className="relative"
|
||
onBlur={(event) => {
|
||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||
setOpen(false)
|
||
setQuery('')
|
||
}
|
||
}}
|
||
>
|
||
<div className="relative">
|
||
<input
|
||
ref={inputRef}
|
||
value={open ? query : selected?.name || ''}
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
onFocus={() => {
|
||
if (!isDisabled) {
|
||
setOpen(true)
|
||
setHighlightedIndex(0)
|
||
}
|
||
}}
|
||
onKeyDown={handleKeyDown}
|
||
disabled={isDisabled}
|
||
placeholder={loading ? '正在加载人员...' : '输入姓名、花名、工号或部门搜索'}
|
||
autoComplete="off"
|
||
role="combobox"
|
||
aria-autocomplete="list"
|
||
aria-expanded={open && !isDisabled}
|
||
aria-controls={listboxId}
|
||
aria-activedescendant={open && filteredOptions[highlightedIndex]
|
||
? `${listboxId}-${filteredOptions[highlightedIndex].id}`
|
||
: undefined}
|
||
aria-busy={loading}
|
||
className="h-11 w-full rounded border border-[#cfd6e1] bg-white px-3 pr-20 text-[14px] outline-none placeholder:text-[#667085] focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:cursor-not-allowed disabled:bg-[#f2f4f7]"
|
||
/>
|
||
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5">
|
||
{value && !isDisabled && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
onChange('')
|
||
setQuery('')
|
||
inputRef.current?.focus()
|
||
}}
|
||
className="flex h-9 w-9 items-center justify-center rounded text-[18px] text-[#667085] hover:bg-[#f2f4f7] hover:text-[#101828] focus:outline-none focus:ring-2 focus:ring-[#dcecff]"
|
||
aria-label="清除已选评审人"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
disabled={isDisabled}
|
||
onClick={() => {
|
||
setOpen((current) => !current)
|
||
inputRef.current?.focus()
|
||
}}
|
||
className="flex h-9 w-9 items-center justify-center rounded text-[#667085] hover:bg-[#f2f4f7] hover:text-[#101828] focus:outline-none focus:ring-2 focus:ring-[#dcecff] disabled:cursor-not-allowed disabled:opacity-45"
|
||
aria-label={open ? '收起评审人列表' : '展开评审人列表'}
|
||
>
|
||
<svg className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="m6 9 6 6 6-6" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{selected && !open && (
|
||
<p className="mt-1.5 truncate text-[11px] font-medium text-[#667085]">
|
||
{[selected.departmentName, selected.positionTitle, selected.jobNumber].filter(Boolean).join(' · ') || '考核人员档案'}
|
||
</p>
|
||
)}
|
||
|
||
{open && !isDisabled && (
|
||
<div
|
||
id={listboxId}
|
||
role="listbox"
|
||
className="absolute z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-[#cfd6e1] bg-white p-1.5 shadow-[0_12px_28px_rgba(16,24,40,0.16)]"
|
||
>
|
||
{filteredOptions.length === 0 ? (
|
||
<div className="px-3 py-5 text-center text-[12px] font-medium text-[#667085]">
|
||
没有找到匹配的评审人
|
||
</div>
|
||
) : filteredOptions.map((option, index) => {
|
||
const metadata = [option.departmentName, option.positionTitle, option.jobNumber].filter(Boolean).join(' · ')
|
||
const active = index === highlightedIndex
|
||
const checked = option.id === value
|
||
return (
|
||
<button
|
||
key={option.id}
|
||
id={`${listboxId}-${option.id}`}
|
||
type="button"
|
||
role="option"
|
||
tabIndex={-1}
|
||
aria-selected={checked}
|
||
onMouseEnter={() => setHighlightedIndex(index)}
|
||
onClick={() => selectOption(option)}
|
||
className={`flex min-h-11 w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left ${
|
||
active ? 'bg-[#eef5ff]' : 'hover:bg-[#f7f9fc]'
|
||
}`}
|
||
>
|
||
<span className="min-w-0">
|
||
<span className="block truncate text-[13px] font-bold text-[#101828]">{option.name}</span>
|
||
{metadata && <span className="mt-0.5 block truncate text-[11px] font-medium text-[#667085]">{metadata}</span>}
|
||
</span>
|
||
{checked && <span className="shrink-0 text-[14px] font-bold text-[#0050cb]" aria-hidden="true">✓</span>}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|