155 lines
9.4 KiB
TypeScript

'use client'
import React, { Children, isValidElement, useEffect, useId, useMemo, useRef, useState, type SelectHTMLAttributes } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown } from 'lucide-react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import { useAnchoredPopover } from './useAnchoredPopover'
import { fieldControlClass } from '@/components/upload/upload-field-styles'
gsap.registerPlugin(useGSAP)
interface Option { value: string; label: string; disabled: boolean }
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
optionCounts?: Record<string, number>
menuHint?: string
}
function nodeText(node: React.ReactNode): string {
return Children.toArray(node).map((child) => isValidElement<{ children?: React.ReactNode }>(child) ? nodeText(child.props.children) : String(child)).join('')
}
function collectOptions(children: React.ReactNode, disabled = false): Option[] {
return Children.toArray(children).flatMap((child) => {
if (!isValidElement<{ children?: React.ReactNode; value?: string | number; label?: string; disabled?: boolean; hidden?: boolean }>(child)) return []
if (child.type === 'option') return child.props.hidden ? [] : [{
value: String(child.props.value ?? nodeText(child.props.children)),
label: child.props.label ?? nodeText(child.props.children),
disabled: disabled || !!child.props.disabled,
}]
return collectOptions(child.props.children, disabled || !!child.props.disabled)
})
}
/** The native select owns form values, validation and change events; the visible control owns presentation. */
export default function Select({ children, className, style, onChange, onInvalid, optionCounts, menuHint, ...props }: SelectProps) {
const generatedId = useId()
const id = props.id || `select-${generatedId}`
const nativeRef = useRef<HTMLSelectElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [active, setActive] = useState(0)
const [internalValue, setInternalValue] = useState(String(props.defaultValue ?? ''))
const [label, setLabel] = useState('')
const [invalid, setInvalid] = useState(false)
const options = useMemo(() => collectOptions(children), [children])
const value = props.value !== undefined ? String(props.value) : internalValue
const selected = options.find((option) => option.value === value) || options[0]
const position = useAnchoredPopover(open, triggerRef)
useEffect(() => {
const native = nativeRef.current
if (!native) return
setLabel(Array.from(triggerRef.current?.labels || []).map((item) => {
const copy = item.cloneNode(true) as HTMLElement
copy.querySelectorAll('select, button, input, textarea, [aria-hidden="true"]').forEach((control) => control.remove())
return copy.textContent?.trim()
}).filter(Boolean).join(' '))
if (props.value === undefined) setInternalValue(native.value)
const form = native.form
const reset = () => queueMicrotask(() => { setInternalValue(native.value); setInvalid(false); setOpen(false) })
form?.addEventListener('reset', reset)
return () => form?.removeEventListener('reset', reset)
}, [props.value, children])
useEffect(() => { if (props.disabled) setOpen(false) }, [props.disabled])
useEffect(() => {
if (!open) return
const pointer = (event: PointerEvent) => {
const target = event.target as Node
if (!menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) setOpen(false)
}
document.addEventListener('pointerdown', pointer)
return () => document.removeEventListener('pointerdown', pointer)
}, [open])
useEffect(() => {
if (open) menuRef.current?.querySelector<HTMLElement>(`[data-option-index="${active}"]`)?.scrollIntoView?.({ block: 'nearest' })
}, [open, active])
useGSAP(() => {
if (!open || !menuRef.current || typeof window.matchMedia !== 'function') return
const motion = gsap.matchMedia()
motion.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo(menuRef.current, { y: -4, opacity: 0.8 }, { y: 0, opacity: 1, duration: 0.16, ease: 'expo.out', clearProps: 'transform,opacity' })
})
return () => motion.revert()
}, { scope: menuRef, dependencies: [open], revertOnUpdate: true })
const choose = (option: Option) => {
if (option.disabled || !nativeRef.current) return
const native = nativeRef.current
native.value = option.value
native.dispatchEvent(new Event('change', { bubbles: true }))
setInternalValue(native.value) // Also respects callers that reset an uncontrolled select in onChange.
setInvalid(false)
setOpen(false)
triggerRef.current?.focus()
}
const show = () => {
const index = options.findIndex((option) => option.value === value && !option.disabled)
setActive(index >= 0 ? index : Math.max(0, options.findIndex((option) => !option.disabled)))
setOpen(true)
}
const keyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Tab') { setOpen(false); return }
if (event.key === 'Escape') { if (open) { event.preventDefault(); event.stopPropagation(); setOpen(false) }; return }
const enabled = options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0)
if (!enabled.length) return
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault()
if (!open) { show(); return }
const current = enabled.indexOf(active)
setActive(event.key === 'Home' ? enabled[0] : event.key === 'End' ? enabled[enabled.length - 1] : enabled[(current + (event.key === 'ArrowDown' ? 1 : -1) + enabled.length) % enabled.length])
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (open && options[active]) choose(options[active]); else show()
} else if (event.key.length === 1) {
const index = options.findIndex((option) => !option.disabled && option.label.toLocaleLowerCase().startsWith(event.key.toLocaleLowerCase()))
if (index >= 0) { event.preventDefault(); if (!open) show(); setActive(index) }
}
}
return (
<span className={`relative inline-grid min-w-0 max-w-full align-middle ${className?.includes('w-full') || !className ? 'w-full' : ''}`}>
<button ref={triggerRef} id={id} type="button" role="combobox" value={selected?.value || ''} disabled={props.disabled} style={style}
aria-label={props['aria-label'] || label || selected?.label || '选择选项'} aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']} aria-required={props.required} aria-invalid={invalid || props['aria-invalid']}
aria-expanded={open} aria-controls={`${id}-options`} aria-haspopup="listbox" aria-activedescendant={open ? `${id}-option-${active}` : undefined}
onClick={() => open ? setOpen(false) : show()} onKeyDown={keyDown}
className={`${className || fieldControlClass} flex min-h-10 min-w-0 items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60 ${invalid ? 'ring-2 ring-[#b42318]' : ''}`}>
<span className="truncate">{selected?.label || '请选择'}</span>
<ChevronDown aria-hidden="true" className={`h-4 w-4 shrink-0 text-[#66758b] transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
<select {...props} id={`${id}-native`} aria-label={undefined} aria-labelledby={undefined} ref={nativeRef} tabIndex={-1} aria-hidden="true" className="sr-only" onFocus={() => triggerRef.current?.focus()}
onChange={(event) => { onChange?.(event); setInternalValue(event.currentTarget.value); setInvalid(false) }}
onInvalid={(event) => { onInvalid?.(event); event.preventDefault(); setInvalid(true); triggerRef.current?.focus() }}>
{children}
</select>
{open && createPortal(
<div ref={menuRef} id={`${id}-options`} role="listbox" aria-label={props['aria-label'] || label || '选项'} aria-labelledby={props['aria-labelledby']}
style={position} className="z-[500] overflow-y-auto overscroll-contain rounded-[12px] bg-white p-1.5 text-[#101828] shadow-[0_12px_32px_rgba(16,24,40,0.18)] ring-1 ring-[#e2e8f0]">
{menuHint && <p className="px-3 py-1.5 text-sm text-[#66758b]">{menuHint}</p>}
{options.length ? options.map((option, index) => (
<div key={`${option.value}-${index}`} id={`${id}-option-${index}`} data-option-index={index} role="option" aria-label={option.label} aria-selected={option.value === selected?.value} aria-disabled={option.disabled}
onClick={() => choose(option)} onPointerMove={() => !option.disabled && setActive(index)}
className={`flex min-h-11 items-center gap-2 rounded-[8px] px-3 py-2 text-sm ${option.disabled ? 'cursor-not-allowed text-[#66758b] opacity-60' : 'cursor-pointer'} ${index === active && !option.disabled ? 'bg-[#eef2f6]' : ''} ${option.value === selected?.value ? 'font-semibold' : ''}`}>
<Check aria-hidden="true" className={`h-4 w-4 shrink-0 ${option.value === selected?.value ? '' : 'invisible'}`} />
<span className="min-w-0 break-words">{option.label}</span>
{optionCounts && <span className="ml-auto shrink-0 text-xs font-normal tabular-nums text-[#66758b]">{(optionCounts[option.value] || 0).toLocaleString('zh-CN')}</span>}
</div>
)) : <p className="px-3 py-2 text-sm text-[#66758b]">暂无可选项</p>}
</div>, document.body
)}
</span>
)
}