gongxiangp2/components/upload/MaterialTypeSelect.tsx

171 lines
5.9 KiB
TypeScript

'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
import { Check, ChevronDown } from 'lucide-react'
import { fieldControlClass } from '@/components/upload/upload-field-styles'
gsap.registerPlugin(useGSAP)
interface MaterialTypeSelectProps {
id: string
value: string
options: string[]
counts: Record<string, number>
onChange: (value: string) => void
disabled?: boolean
}
export default function MaterialTypeSelect({
id,
value,
options,
counts,
onChange,
disabled = false,
}: MaterialTypeSelectProps) {
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const rankedOptions = useMemo(() => {
if (!options.some((option) => (counts[option] || 0) > 0)) return options
return [...options].sort((left, right) => (counts[right] || 0) - (counts[left] || 0))
}, [options, counts])
const hasCounts = rankedOptions.some((option) => (counts[option] || 0) > 0)
useEffect(() => {
if (!isOpen) return
setActiveIndex(Math.max(0, rankedOptions.indexOf(value)))
menuRef.current?.focus()
}, [isOpen, value, rankedOptions])
useEffect(() => {
if (!isOpen) return
const onPointerDown = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) setIsOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
return () => document.removeEventListener('pointerdown', onPointerDown)
}, [isOpen])
useEffect(() => {
if (disabled) setIsOpen(false)
}, [disabled])
useEffect(() => {
if (!isOpen) return
menuRef.current?.querySelector<HTMLElement>(`[data-option-index="${activeIndex}"]`)?.scrollIntoView?.({ block: 'nearest' })
}, [activeIndex, isOpen])
useGSAP(() => {
if (!isOpen || !menuRef.current || typeof window.matchMedia !== 'function') return
const motion = gsap.matchMedia()
motion.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo(menuRef.current, { autoAlpha: 0, y: -6 }, {
autoAlpha: 1,
y: 0,
duration: 0.16,
ease: 'power2.out',
clearProps: 'opacity,visibility,transform',
})
})
return () => motion.revert()
}, { scope: containerRef, dependencies: [isOpen], revertOnUpdate: true })
const close = () => {
setIsOpen(false)
triggerRef.current?.focus()
}
const select = (nextValue: string) => {
onChange(nextValue)
close()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
event.preventDefault()
close()
return
}
if (event.key === 'Tab' && isOpen) {
setIsOpen(false)
return
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!isOpen) {
setIsOpen(true)
} else {
setActiveIndex((index) => Math.max(0, Math.min(rankedOptions.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1))))
}
return
}
if (!isOpen) return
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
setActiveIndex(event.key === 'Home' ? 0 : rankedOptions.length - 1)
} else if ((event.key === 'Enter' || event.key === ' ') && rankedOptions[activeIndex]) {
event.preventDefault()
select(rankedOptions[activeIndex])
}
}
return (
<div ref={containerRef} className="relative" onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
id={id}
type="button"
disabled={disabled}
aria-labelledby={`${id}-label`}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={`${id}-options`}
onClick={() => setIsOpen((open) => !open)}
className={`${fieldControlClass} flex min-h-11 items-center justify-between gap-3 text-left`}
>
<span className="truncate">{value}</span>
<ChevronDown aria-hidden="true" className={`h-4 w-4 shrink-0 text-[#6b7075] transition-transform duration-150 ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div
ref={menuRef}
id={`${id}-options`}
role="listbox"
tabIndex={-1}
aria-labelledby={`${id}-label`}
aria-activedescendant={`${id}-option-${activeIndex}`}
className="absolute left-0 right-0 top-full z-40 mt-2 max-h-80 overflow-y-auto overscroll-contain rounded-[12px] border border-[#e7e8ea] bg-white p-1.5 text-[#1a1c1d] shadow-[0_12px_28px_rgba(26,28,29,0.12)] outline-none"
>
{hasCounts && <div className="px-2.5 py-1.5 text-sm font-medium text-[#6b7075]">按素材数量排序</div>}
{rankedOptions.map((option, index) => (
<div
key={option}
id={`${id}-option-${index}`}
data-option-index={index}
role="option"
aria-label={option}
aria-selected={option === value}
onClick={() => select(option)}
onPointerMove={() => setActiveIndex(index)}
className={`flex min-h-10 cursor-pointer items-center justify-between gap-3 rounded-[8px] px-2.5 py-2 text-[14px] tracking-[-0.02em] ${index === activeIndex ? 'bg-[#f3f4f5]' : 'hover:bg-[#f3f4f5]'} ${option === value ? 'font-medium' : ''}`}
>
<span className="flex min-w-0 items-center gap-2">
<Check aria-hidden="true" className={`h-4 w-4 shrink-0 ${option === value ? 'text-[#1a1c1d]' : 'opacity-0'}`} />
<span className="truncate">{option}</span>
</span>
{hasCounts && <span className="font-en shrink-0 text-sm tabular-nums text-[#6b7075]">{(counts[option] || 0).toLocaleString('zh-CN')}</span>}
</div>
))}
</div>
)}
</div>
)
}