import { useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' import styles from './MultiSelectDropdown.module.css' export interface MultiSelectOption { value: T label: string depth?: number } interface MultiSelectDropdownProps { options: MultiSelectOption[] value: T[] onChange: (value: T[]) => void placeholder?: string searchPlaceholder?: string disabled?: boolean id?: string } export function MultiSelectDropdown({ options, value, onChange, placeholder = 'Select options', searchPlaceholder = 'Search...', disabled = false, id, }: MultiSelectDropdownProps) { const [open, setOpen] = useState(false) const containerRef = useRef(null) const [searchQuery, setSearchQuery] = useState('') const searchInputRef = useRef(null) const selectedLabels = useMemo( () => options .filter((option) => value.includes(option.value)) .map((option) => option.label), [options, value], ) const filteredOptions = useMemo(() => { const query = searchQuery.trim().toLowerCase() if (!query) return options return options.filter((o) => o.label.toLowerCase().includes(query)) }, [options, searchQuery]) const triggerLabel = useMemo(() => { if (selectedLabels.length === 0) return placeholder if (selectedLabels.length <= 2) return selectedLabels.join(', ') return `${selectedLabels.slice(0, 2).join(', ')} +${selectedLabels.length - 2}` }, [placeholder, selectedLabels]) useEffect(() => { if (!open) return setSearchQuery('') const t = window.setTimeout(() => { searchInputRef.current?.focus() }, 0) function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false) } } function handleEscape(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false) } document.addEventListener('mousedown', handleClickOutside) document.addEventListener('keydown', handleEscape) return () => { window.clearTimeout(t) document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('keydown', handleEscape) } }, [open]) function toggleOption(optionValue: T) { if (value.includes(optionValue)) { onChange(value.filter((item) => item !== optionValue)) return } onChange([...value, optionValue]) } return (
{open && (
  • setSearchQuery(e.target.value)} placeholder={searchPlaceholder} aria-label="Search options" disabled={disabled} />
  • {filteredOptions.length === 0 ? (
  • {options.length === 0 ? 'No options available.' : 'No matching options.'}
  • ) : ( filteredOptions.map((option) => { const selected = value.includes(option.value) return (
  • ) }) )}
)}
) }