/** * Searchable Select Component * Combo box with search/filter functionality for large option lists */ import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, Search, X } from 'lucide-react'; interface SearchableSelectProps { value: string; onChange: (value: string) => void; options: string[]; placeholder?: string; required?: boolean; className?: string; zIndex?: number; // Custom z-index for the dropdown } export const SearchableSelect: React.FC = ({ value, onChange, options, placeholder = 'Select...', required = false, className = '', zIndex = 110, // Default z-index for dropdown }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [highlightedIndex, setHighlightedIndex] = useState(0); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); const containerRef = useRef(null); const inputRef = useRef(null); const listRef = useRef(null); const dropdownRef = useRef(null); // Filter options based on search term const filteredOptions = options.filter((option) => option.toLowerCase().includes(searchTerm.toLowerCase()) ); // Close dropdown on scroll (prevents it from covering modal header) useEffect(() => { if (isOpen && containerRef.current) { const updatePosition = () => { const rect = containerRef.current!.getBoundingClientRect(); // Always position below the trigger setDropdownPosition({ top: rect.bottom + 4, left: rect.left, width: rect.width, }); }; updatePosition(); // Close dropdown on scroll (prevents it from covering modal header) const handleScroll = (e: Event) => { // Only close if scroll is not from the dropdown itself if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setIsOpen(false); } }; // Update position on resize, close on scroll window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', updatePosition); return () => { window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', updatePosition); }; } }, [isOpen]); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(event.target as Node) && dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { setIsOpen(false); setSearchTerm(''); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [isOpen]); // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (!isOpen) { if (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === 'ArrowUp') { setIsOpen(true); e.preventDefault(); } return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); setHighlightedIndex((prev) => prev < filteredOptions.length - 1 ? prev + 1 : prev ); break; case 'ArrowUp': e.preventDefault(); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); break; case 'Enter': e.preventDefault(); if (filteredOptions[highlightedIndex]) { handleSelect(filteredOptions[highlightedIndex]); } break; case 'Escape': setIsOpen(false); setSearchTerm(''); break; } }; // Scroll highlighted item into view useEffect(() => { if (isOpen && listRef.current) { const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement; if (highlightedElement) { highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } }, [highlightedIndex, isOpen]); // Reset highlighted index when filtered options change useEffect(() => { setHighlightedIndex(0); }, [searchTerm]); const handleSelect = (option: string) => { onChange(option); setIsOpen(false); setSearchTerm(''); }; const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); onChange(''); setSearchTerm(''); inputRef.current?.focus(); }; const handleToggle = () => { setIsOpen(!isOpen); if (!isOpen) { // Focus input when opening setTimeout(() => inputRef.current?.focus(), 0); } }; // Get display text const displayText = value || ''; // Render dropdown in portal (to avoid being clipped by Modal overflow) const renderDropdown = () => { if (!isOpen) return null; return createPortal(
{/* Search Input */}
setSearchTerm(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type to search..." className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-300 text-slate-900 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" autoFocus />
{/* Options List */}
    {filteredOptions.length === 0 ? (
  • No results found
  • ) : ( filteredOptions.map((option, index) => (
  • handleSelect(option)} className={`px-4 py-3 text-sm cursor-pointer transition border-b border-slate-100 last:border-0 ${ index === highlightedIndex ? 'bg-blue-600 text-white' : value === option ? 'bg-blue-50 text-blue-700' : 'text-slate-700 hover:bg-slate-50' }`} role="option" aria-selected={value === option} >
    {option}
  • )) )}
{/* Result Count - Always show for large lists */} {(searchTerm || options.length > 20) && (
{searchTerm ? `${filteredOptions.length} of ${options.length} options` : `${options.length} total options` } {!searchTerm && options.length > 20 && ( Type to search )}
)}
, document.body ); }; return (
{/* Trigger Button/Input */}
{displayText || placeholder}
{value && ( )}
{/* Required indicator (hidden input for form validation) */} {required && ( {}} required className="absolute opacity-0 pointer-events-none" tabIndex={-1} /> )} {/* Dropdown (rendered via portal to avoid Modal overflow clipping) */} {renderDropdown()}
); };