'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 { optionCounts?: Record 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(null) const triggerRef = useRef(null) const menuRef = useRef(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(`[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) => { 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 ( {open && createPortal(
{menuHint &&

{menuHint}

} {options.length ? options.map((option, index) => (
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' : ''}`}>
)) :

暂无可选项

}
, document.body )}
) }