'use client'; import { useMemo, useState } from 'react'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Check, ChevronDown, Search, X } from 'lucide-react'; export interface MultiSelectOption { value: string; label: string; } interface Props { options: MultiSelectOption[]; value: string[]; onChange: (next: string[]) => void; placeholder?: string; searchPlaceholder?: string; className?: string; /** Hide the search box when option count is below this threshold. */ searchThreshold?: number; /** Optional max-height for the option list (px). */ maxListHeight?: number; } export function MultiSelect({ options, value, onChange, placeholder = 'Any', searchPlaceholder = 'Search…', className = '', searchThreshold = 8, maxListHeight = 300, }: Props) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const valueSet = useMemo(() => new Set(value), [value]); const filtered = useMemo(() => { const s = search.trim().toLowerCase(); if (!s) return options; return options.filter((o) => o.label.toLowerCase().includes(s)); }, [options, search]); const selectedLabels = useMemo(() => { if (value.length === 0) return null; if (value.length === 1) { return options.find((o) => o.value === value[0])?.label ?? value[0]; } return `${value.length} selected`; }, [value, options]); function toggle(optValue: string) { if (valueSet.has(optValue)) { onChange(value.filter((v) => v !== optValue)); } else { onChange([...value, optValue]); } } function clear(e: React.MouseEvent) { e.stopPropagation(); onChange([]); } return ( {options.length >= searchThreshold && (
setSearch(e.target.value)} placeholder={searchPlaceholder} className="pl-8 h-8" />
)}
{filtered.length === 0 ? (
No matches
) : ( filtered.map((opt) => { const checked = valueSet.has(opt.value); return (
toggle(opt.value)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(opt.value); } }} className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-accent cursor-pointer select-none focus:bg-accent focus:outline-none" > {opt.label}
); }) )}
); }