import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { __ } from '@/utils/i18n'; import { Check, ChevronsUpDown, X } from 'lucide-react'; import { useMemo, useState } from 'react'; export interface MultiSelectOption { value: string; label: string; icon?: React.ReactNode; badgeClassName?: string; /** Optional hierarchy: indentation level and parent for tree-aware search. */ depth?: number; parentValue?: string | null; } interface Props { options: MultiSelectOption[]; selected: string[]; onChange: (selected: string[]) => void; placeholder?: string; searchPlaceholder?: string; emptyText?: string; id?: string; className?: string; } export function MultiSelect({ options, selected, onChange, placeholder = __('Select…'), searchPlaceholder = __('Search…'), emptyText = __('No results.'), id, className, }: Props) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const isTree = options.some( (option) => option.depth != null || option.parentValue != null, ); // Tree-aware search: keep each match together with its ancestors so a // matching child still shows its parent chain for context. const visibleOptions = useMemo(() => { const query = search.trim().toLowerCase(); if (!isTree || !query) { return options; } const parentOf = new Map( options.map((option) => [option.value, option.parentValue ?? null]), ); const include = new Set(); for (const option of options) { if (!option.label.toLowerCase().includes(query)) { continue; } let value: string | null | undefined = option.value; let guard = 0; while (value != null && guard++ < 10) { include.add(value); value = parentOf.get(value); } } return options.filter((option) => include.has(option.value)); }, [options, isTree, search]); const toggle = (value: string) => { if (selected.includes(value)) { onChange(selected.filter((item) => item !== value)); } else { onChange([...selected, value]); } }; const remove = (value: string) => { onChange(selected.filter((item) => item !== value)); }; const selectedOptions = options.filter((option) => selected.includes(option.value), ); return (
{isTree ? ( visibleOptions.length === 0 && (
{emptyText}
) ) : ( {emptyText} )} {visibleOptions.map((option) => { const isSelected = selected.includes( option.value, ); return ( toggle(option.value)} style={ option.depth ? { paddingLeft: `${option.depth * 1.25}rem`, } : undefined } > {option.icon && ( {option.icon} )} {option.label} ); })}
{selectedOptions.length > 0 && (
{selectedOptions.map((option) => ( {option.icon} {option.label} ))}
)}
); }