import React, { useEffect, useMemo, useRef, useState } from 'react'; import './fields.css'; export type SearchableMultiSelectItem = { value: string; label: string; description?: string; meta?: string; inactive?: boolean; }; type SearchableMultiSelectProps = { label?: string; description?: string; value: string[]; items: SearchableMultiSelectItem[]; onChange: (values: string[]) => void; placeholder?: string; searchPlaceholder?: string; loading?: boolean; error?: string | null; emptyMessage?: string; noResultsMessage?: string; }; export const SearchableMultiSelect: React.FC = ({ label, description, value, items, onChange, placeholder = 'Select options…', searchPlaceholder = 'Search…', loading = false, error = null, emptyMessage = 'No options are available.', noResultsMessage = 'No matching options found.', }) => { const rootRef = useRef(null); const inputRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const selectedLookup = useMemo(() => new Set(value), [value]); const selectedItems = useMemo( () => value.map((selectedValue) => items.find((item) => item.value === selectedValue)).filter(Boolean) as SearchableMultiSelectItem[], [items, value] ); const filteredItems = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); return items.filter((item) => { if (selectedLookup.has(item.value)) { return false; } if (!normalizedQuery) { return true; } return [item.label, item.meta, item.description] .filter(Boolean) .some((part) => (part as string).toLowerCase().includes(normalizedQuery)); }); }, [items, query, selectedLookup]); useEffect(() => { const handlePointerDown = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) { setOpen(false); setQuery(''); } }; document.addEventListener('mousedown', handlePointerDown); return () => { document.removeEventListener('mousedown', handlePointerDown); }; }, []); const addValue = (nextValue: string) => { if (!selectedLookup.has(nextValue)) { onChange([...value, nextValue]); } setQuery(''); inputRef.current?.focus(); }; const removeValue = (nextValue: string) => { onChange(value.filter((currentValue) => currentValue !== nextValue)); }; const hasItems = items.length > 0; return (
{label &&
{label}
}
{ setOpen((current) => !current); window.setTimeout(() => inputRef.current?.focus(), 0); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpen((current) => !current); window.setTimeout(() => inputRef.current?.focus(), 0); } }} >
{selectedItems.map((item) => ( {item.label} ))} {!selectedItems.length && ( {placeholder} )}
{open && (
setQuery(event.target.value)} placeholder={searchPlaceholder} className="ips-searchable-multiselect__input" />
{loading &&
Loading options…
} {!loading && error && (
{error}
)} {!loading && !error && !hasItems && (
{emptyMessage}
)} {!loading && !error && hasItems && !filteredItems.length && (
{query.trim() ? noResultsMessage : 'All options are already selected.'}
)} {!loading && !error && filteredItems.map((item) => ( ))}
)}
{description &&
{description}
}
); };