File size: 3,351 Bytes
1f21206 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | import { useState, useRef, useEffect, type CSSProperties, type ReactNode } from 'react'
type DropdownItem<T extends string> = {
value: T
label: string
description?: string
icon?: ReactNode
}
type DropdownProps<T extends string> = {
items: DropdownItem<T>[]
value: T
onChange: (value: T) => void
trigger: ReactNode
width?: CSSProperties['width']
maxHeight?: CSSProperties['maxHeight']
align?: 'left' | 'right'
className?: string
}
export function Dropdown<T extends string>({
items,
value,
onChange,
trigger,
width = 320,
maxHeight,
align = 'left',
className = '',
}: DropdownProps<T>) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
}
}, [open])
return (
<div ref={ref} className={`relative ${className || 'inline-block'}`}>
<div onClick={() => setOpen(!open)} className="cursor-pointer">
{trigger}
</div>
{open && (
<div
className={`
absolute z-50 mt-1 rounded-[var(--radius-lg)]
bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)]
shadow-[var(--shadow-dropdown)]
animate-in fade-in slide-in-from-top-1
${maxHeight ? 'overflow-y-auto' : 'overflow-hidden'}
${align === 'right' ? 'right-0' : 'left-0'}
`}
style={{ width, maxHeight }}
>
{items.map((item, i) => (
<button
key={item.value}
onClick={() => { onChange(item.value); setOpen(false) }}
className={`
w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors
hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]
${item.value === value ? 'bg-[var(--color-model-option-selected-bg)]' : ''}
${i > 0 ? 'border-t border-[var(--color-border-separator)]' : ''}
`}
>
{item.icon && <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center text-[var(--color-text-secondary)]">{item.icon}</span>}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--color-text-secondary)] mt-0.5">{item.description}</div>
)}
</div>
{item.value === value && (
<span className="material-symbols-outlined flex-shrink-0 text-[16px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
)}
</button>
))}
</div>
)}
</div>
)
}
|