171 lines
5.9 KiB
TypeScript
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>
|
|
)
|
|
}
|