import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { Icon } from '@/components/common/Icon'
export interface SelectOption {
value: string
label: string
icon?: string
description?: string
}
interface SelectProps {
options: SelectOption[]
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
disabled?: boolean
}
/** 下拉选择组件 —— 适配 Rainbow 主题的 Portal 下拉菜单,支持键盘导航 */
export function Select({
options,
value,
onChange,
placeholder,
className,
disabled = false,
}: SelectProps) {
const [open, setOpen] = useState(false)
const [focusedIndex, setFocusedIndex] = useState(-1)
const [openUpward, setOpenUpward] = useState(false)
const [dropdownPos, setDropdownPos] = useState({ left: 0, top: 0, width: 0 })
const containerRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const selected = options.find((o) => o.value === value)
const close = useCallback(() => {
setOpen(false)
setFocusedIndex(-1)
}, [])
const handleOpen = useCallback(() => {
if (disabled) return
if (!open) {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const upward = spaceBelow < 220
setOpenUpward(upward)
setDropdownPos({
left: rect.left,
top: upward ? rect.top - 4 : rect.bottom + 4,
width: rect.width,
})
}
}
setOpen((v) => !v)
}, [disabled, open])
// 点击外部或滚动/缩放时关闭下拉
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
const target = e.target as Node
const inTrigger = containerRef.current?.contains(target) ?? false
const inDropdown = dropdownRef.current?.contains(target) ?? false
if (!inTrigger && !inDropdown) {
close()
}
}
document.addEventListener('mousedown', handler)
window.addEventListener('scroll', (e: Event) => {
if (dropdownRef.current?.contains(e.target as Node)) return
close()
}, { capture: true })
window.addEventListener('resize', close)
return () => {
document.removeEventListener('mousedown', handler)
window.removeEventListener('scroll', close, { capture: true })
window.removeEventListener('resize', close)
}
}, [open, close])
const handleKeyDown = (e: KeyboardEvent) => {
if (disabled) return
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault()
if (!open) {
handleOpen()
setFocusedIndex(options.findIndex((o) => o.value === value))
} else if (focusedIndex >= 0) {
onChange(options[focusedIndex].value)
close()
}
break
case 'ArrowDown':
e.preventDefault()
if (!open) {
setOpen(true)
setFocusedIndex(options.findIndex((o) => o.value === value))
} else {
setFocusedIndex((i) => (i + 1) % options.length)
}
break
case 'ArrowUp':
e.preventDefault()
if (open) {
setFocusedIndex((i) => (i - 1 + options.length) % options.length)
}
break
case 'Escape':
e.preventDefault()
close()
break
}
}
return (
<div ref={containerRef} className={cn('relative', className)}>
<button
type="button"
onClick={handleOpen}
onKeyDown={handleKeyDown}
disabled={disabled}
className={cn(
'flex items-center justify-between w-full rounded-lg border min-h-[36px]',
'bg-[var(--color-surface-0)] text-sm',
'px-3 py-2 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-brand-500)]/55',
open
? 'border-[color:var(--color-brand-500)] ring-1 ring-[color:var(--color-brand-500)]/50'
: 'border-[var(--color-border-default)] hover:border-[var(--color-border-strong)]',
disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer',
)}
>
<span className={cn(
'truncate',
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-tertiary)]',
)}>
{selected?.label ?? placeholder ?? ''}
</span>
<Icon
name="unfold_more"
size="sm"
className={cn(
'ml-2 flex-shrink-0 text-[var(--color-text-tertiary)] transition-transform',
open && 'text-primary',
)}
/>
</button>
{open && createPortal(
<div
ref={dropdownRef}
style={{
position: 'fixed',
left: `${dropdownPos.left}px`,
top: `${dropdownPos.top}px`,
width: `${dropdownPos.width}px`,
transform: openUpward ? 'translateY(-100%)' : '',
}}
className={cn(
'z-50 min-w-[120px]',
'bg-[var(--color-surface-0)] rounded-xl',
'border border-[var(--color-border-subtle)]',
'shadow-[var(--shadow-menu)]',
'py-1 overflow-auto max-h-60',
'animate-slide-up',
)}
>
{options.map((opt, idx) => {
const isSelected = opt.value === value
const isFocused = idx === focusedIndex
return (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value)
close()
}}
onMouseEnter={() => setFocusedIndex(idx)}
className={cn(
'flex items-center w-full px-3 py-2 text-sm text-left transition-colors',
isFocused && 'bg-[var(--color-surface-2)]',
isSelected
? 'text-primary font-medium'
: 'text-[var(--color-text-primary)]',
)}
>
{opt.icon && (
<Icon name={opt.icon} size="sm" className="mr-2 text-[var(--color-text-tertiary)]" />
)}
<span className="flex-1 truncate">{opt.label}</span>
{isSelected && (
<Icon name="check" size="sm" className="ml-2 text-primary flex-shrink-0" />
)}
</button>
)
})}
</div>,
document.body,
)}
</div>
)
}
|