import { useState, useRef, useEffect, useCallback } from 'react'; import { ChevronDown } from 'lucide-react'; import './CustomSelect.css'; export interface CustomSelectOption { value: string; label: string; } interface CustomSelectProps { value: string; onChange: (value: string) => void; options: CustomSelectOption[]; ariaLabel?: string; } export function CustomSelect({ value, onChange, options, ariaLabel }: CustomSelectProps) { const [isOpen, setIsOpen] = useState(false); const [focusedIndex, setFocusedIndex] = useState(-1); const containerRef = useRef(null); const listRef = useRef(null); const triggerRef = useRef(null); const typeAheadBuffer = useRef(''); const typeAheadTimer = useRef | undefined>(undefined); const selectedOption = options.find(opt => opt.value === value); const selectedLabel = selectedOption?.label ?? ''; const close = useCallback(() => { setIsOpen(false); setFocusedIndex(-1); triggerRef.current?.focus(); }, []); const toggle = useCallback(() => { setIsOpen(prev => { if (!prev) { const idx = options.findIndex(opt => opt.value === value); setFocusedIndex(idx >= 0 ? idx : 0); } return !prev; }); }, [options, value]); const selectOption = useCallback( (optValue: string) => { onChange(optValue); close(); }, [onChange, close], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!isOpen) { if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); toggle(); } return; } switch (e.key) { case 'Escape': e.preventDefault(); close(); break; case 'ArrowDown': e.preventDefault(); setFocusedIndex(prev => (prev < options.length - 1 ? prev + 1 : 0)); break; case 'ArrowUp': e.preventDefault(); setFocusedIndex(prev => (prev > 0 ? prev - 1 : options.length - 1)); break; case 'Home': e.preventDefault(); setFocusedIndex(0); break; case 'End': e.preventDefault(); setFocusedIndex(options.length - 1); break; case 'Enter': case ' ': e.preventDefault(); if (focusedIndex >= 0 && focusedIndex < options.length) { selectOption(options[focusedIndex].value); } break; case 'Tab': close(); break; default: if (e.key.length === 1) { typeAheadBuffer.current += e.key.toLowerCase(); clearTimeout(typeAheadTimer.current); typeAheadTimer.current = setTimeout(() => { typeAheadBuffer.current = ''; }, 500); const match = options.findIndex(opt => opt.label.toLowerCase().startsWith(typeAheadBuffer.current), ); if (match >= 0) setFocusedIndex(match); } break; } }, [isOpen, options, focusedIndex, toggle, close, selectOption], ); useEffect(() => { if (!isOpen) return; const handleOutsideClick = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { close(); } }; document.addEventListener('mousedown', handleOutsideClick); return () => document.removeEventListener('mousedown', handleOutsideClick); }, [isOpen, close]); useEffect(() => { if (isOpen && focusedIndex >= 0 && listRef.current) { const items = listRef.current.querySelectorAll('.custom-select-option'); items[focusedIndex]?.focus(); } }, [isOpen, focusedIndex]); useEffect(() => { return () => clearTimeout(typeAheadTimer.current); }, []); return (
{isOpen && (
{options.map((option, index) => ( ))}
)}
); }