gongxiangp2/components/work-review/WorkReviewReviewerSelect.tsx
2026-08-01 09:46:59 +08:00

211 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}