import { useEffect, useId, useMemo, useRef, useState } from "react"; import { Check, ChevronDown, Search, X } from "lucide-react"; import "./MultiSelect.css"; export interface SelectOption { value: string; label: string; depth?: number; } interface MultiSelectProps { label: string; options: SelectOption[]; value: string[]; onChange: (value: string[]) => void; placeholder?: string; } export function MultiSelect({ label, options, value, onChange, placeholder = "Search categories…", }: MultiSelectProps) { const id = useId(); const rootRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const selected = useMemo( () => options.filter((o) => value.includes(o.value)), [options, value], ); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return options; // Keep matches and their ancestors so hierarchy stays readable while searching const matched = new Set( options.filter((o) => o.label.toLowerCase().includes(q)).map((o) => o.value), ); if (matched.size === 0) return []; const indexByValue = new Map(options.map((o, i) => [o.value, i])); const keep = new Set(matched); for (const option of options) { if (!matched.has(option.value)) continue; const depth = option.depth ?? 0; if (depth === 0) continue; const idx = indexByValue.get(option.value) ?? 0; for (let i = idx - 1; i >= 0; i -= 1) { const ancestor = options[i]; if ((ancestor.depth ?? 0) < depth) { keep.add(ancestor.value); if ((ancestor.depth ?? 0) === 0) break; } } } return options.filter((o) => keep.has(o.value)); }, [options, query]); useEffect(() => { function onDocClick(e: MouseEvent) { if (!rootRef.current?.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", onDocClick); return () => document.removeEventListener("mousedown", onDocClick); }, []); function toggle(optionValue: string) { if (value.includes(optionValue)) { onChange(value.filter((v) => v !== optionValue)); } else { onChange([...value, optionValue]); } } function remove(optionValue: string) { onChange(value.filter((v) => v !== optionValue)); } return (
{open ? (
setQuery(e.target.value)} placeholder={placeholder} autoFocus />
    {filtered.length === 0 ? (
  • No categories found
  • ) : ( filtered.map((option) => { const active = value.includes(option.value); const depth = option.depth ?? 0; return (
  • ); }) )}
) : null}
); }