import React, { useState, useRef, useEffect, useMemo } from 'react'; import { SearchIcon } from './Icons'; interface MultiSelectDropdownProps { label: string; selected: string[]; options: string[]; onChange: (newSelected: string[]) => void; className?: string; enableRangeSelect?: boolean; } const MultiSelectDropdown: React.FC = ({ label, selected, options, onChange, className, enableRangeSelect }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [rangeInput, setRangeInput] = useState(''); // Local optimistic state so checkboxes respond instantly on click const [localSelected, setLocalSelected] = useState(selected); const dropdownRef = useRef(null); const inputRef = useRef(null); // Sync local state when committed prop changes (after deferred update settles) useEffect(() => { setLocalSelected(selected); }, [selected]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Focus input when opening useEffect(() => { if (isOpen && inputRef.current) { inputRef.current.focus(); } if (!isOpen) { setSearchTerm(''); // Reset search when closing } }, [isOpen]); // Prevent body scroll when dropdown is open on mobile useEffect(() => { if (isOpen && window.innerWidth < 768) { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; } }, [isOpen]); const filteredOptions = useMemo(() => { if (!searchTerm) return options; return options.filter(opt => opt.toLowerCase().includes(searchTerm.toLowerCase())); }, [options, searchTerm]); const toggleOption = (option: string) => { const next = localSelected.includes(option) ? localSelected.filter(item => item !== option) : localSelected.concat([option]); setLocalSelected(next); onChange(next); }; const handleSelectAll = () => { let next: string[]; if (searchTerm) { const allFilteredSelected = filteredOptions.every(opt => localSelected.includes(opt)); next = allFilteredSelected ? localSelected.filter(item => !filteredOptions.includes(item)) : Array.from(new Set(localSelected.slice().concat(filteredOptions))); } else { next = localSelected.length === options.length ? [] : options.slice(); } setLocalSelected(next); onChange(next); }; const handleClear = () => { setLocalSelected([]); onChange([]); }; const handleRangeApply = () => { if (!rangeInput.trim()) return; const input = rangeInput.trim().toLowerCase().replace('w', ''); // Allow "W5" -> "5" let predicate: (num: number) => boolean = () => false; // Parse Input if (input.includes('-')) { const [start, end] = input.split('-').map(s => parseFloat(s.trim())); if (!isNaN(start) && !isNaN(end)) { predicate = (n) => n >= start && n <= end; } } else if (input.startsWith('<=')) { const val = parseFloat(input.substring(2).trim()); if (!isNaN(val)) predicate = (n) => n <= val; } else if (input.startsWith('>=')) { const val = parseFloat(input.substring(2).trim()); if (!isNaN(val)) predicate = (n) => n >= val; } else if (input.startsWith('<')) { const val = parseFloat(input.substring(1).trim()); if (!isNaN(val)) predicate = (n) => n < val; } else if (input.startsWith('>')) { const val = parseFloat(input.substring(1).trim()); if (!isNaN(val)) predicate = (n) => n > val; } else { // Exact match const val = parseFloat(input); if (!isNaN(val)) predicate = (n) => n === val; } // Apply to Options const newSelected = options.filter(opt => { // Extract number from option (e.g. "W1" -> 1, "2023" -> 2023) const num = parseFloat(opt.replace(/\D/g, '')); if (isNaN(num)) return false; return predicate(num); }); onChange(newSelected); setRangeInput(''); // Clear input after apply }; return (
{/* Mobile overlay backdrop */} {isOpen && (
setIsOpen(false)} /> )} {isOpen && (
{/* Mobile header with close */}

{label}

{/* Search Bar */}
setSearchTerm(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); // Check if input contains multiple space-separated terms (bulk paste) const terms = searchTerm.trim().split(/\s+/).filter(Boolean); if (terms.length > 1) { // Bulk select: match each term against options (case-insensitive) const termsLower = terms.map(t => t.toLowerCase()); const matched = options.filter(opt => termsLower.some(t => opt.toLowerCase() === t || opt.toLowerCase().includes(t)) ); if (matched.length > 0) { const next = Array.from(new Set(localSelected.slice().concat(matched))); setLocalSelected(next); onChange(next); } setIsOpen(false); setSearchTerm(''); } else if (filteredOptions.length > 0) { const allSelected = filteredOptions.every(opt => localSelected.includes(opt)); let next: string[]; if (allSelected) { next = localSelected.filter(item => !filteredOptions.includes(item)); } else { next = Array.from(new Set(localSelected.slice().concat(filteredOptions))); } setLocalSelected(next); onChange(next); setIsOpen(false); setSearchTerm(''); } } }} />
{/* Range Filter (Optional) */} {enableRangeSelect && (
setRangeInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleRangeApply()} />
)}
{filteredOptions.map((opt) => ( ))} {filteredOptions.length === 0 &&
No matches found
}
)}
); }; export default MultiSelectDropdown;