155 lines
9.4 KiB
TypeScript
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>
|
|
)
|
|
}
|