import { useState, useRef, useEffect, useCallback, useId } from 'react' import { createPortal } from 'react-dom' import { IconChevronDownFilled } from '@tabler/icons-react' interface Option { value: string | number label: string } interface Props { value: string | number options: Option[] onChange: (value: string | number) => void className?: string style?: React.CSSProperties } export default function Select({ value, options, onChange, className, style }: Props) { const [open, setOpen] = useState(false) const triggerRef = useRef(null) const dropRef = useRef(null) const optionRefs = useRef<(HTMLButtonElement | null)[]>([]) const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }) const listboxId = useId() const selected = options.find(o => String(o.value) === String(value)) const selectedIndex = options.findIndex(o => String(o.value) === String(value)) const close = useCallback(() => setOpen(false), []) const updatePos = useCallback(() => { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() setPos({ top: rect.bottom, left: rect.left, width: rect.width }) }, []) // move real focus to the option matching the current value (or the first) useEffect(() => { if (!open) return updatePos() const target = optionRefs.current[selectedIndex >= 0 ? selectedIndex : 0] target?.focus() }, [open, selectedIndex, updatePos]) useEffect(() => { if (!open) return function onDown(e: MouseEvent) { const target = e.target if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) && triggerRef.current && !triggerRef.current.contains(target)) { setOpen(false) } } window.addEventListener('scroll', updatePos, true) document.addEventListener('mousedown', onDown) return () => { window.removeEventListener('scroll', updatePos, true) document.removeEventListener('mousedown', onDown) } }, [open, updatePos]) const moveFocus = useCallback((delta: number) => { const refs = optionRefs.current const current = refs.findIndex(el => el === document.activeElement) const next = Math.min(refs.length - 1, Math.max(0, (current >= 0 ? current : 0) + delta)) refs[next]?.focus() }, []) // trigger-level keys: arrows cycle values like a native select while closed const onTriggerKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() const delta = e.key === 'ArrowDown' ? 1 : -1 const next = Math.min(options.length - 1, Math.max(0, selectedIndex + delta)) if (next !== selectedIndex && options[next]) onChange(options[next].value) return } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() setOpen(!open) return } if (e.key === 'Escape') { close() } } const onOptionKeyDown = (e: React.KeyboardEvent, index: number) => { if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(1); return } if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(-1); return } if (e.key === 'Home') { e.preventDefault(); optionRefs.current[0]?.focus(); return } if (e.key === 'End') { e.preventDefault(); optionRefs.current[options.length - 1]?.focus(); return } if (e.key === 'Escape') { e.preventDefault(); close(); triggerRef.current?.focus(); return } if (e.key === 'Tab') { close(); return } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() const o = options[index] if (o) { onChange(o.value); close(); triggerRef.current?.focus() } } } return (
{open && createPortal(
{options.map((o, i) => ( ))}
, document.body, )}
) }