/**
* SearchableMultiSelect Component
*
* A dropdown multi-select component with fuzzy search capability.
* Designed for large option lists (e.g., 50+ languages, services, etc.)
* where users need to quickly find and select multiple items.
*
* Features:
* - Fuzzy search for finding options quickly
* - Multi-select with selected items shown as chips/tags
* - Keyboard navigation support
* - Accessible (ARIA compliant)
* - Theme-aware styling
*/
import { useState, useRef, useEffect, useCallback, useMemo, forwardRef } from 'react';
import type React from 'react';
import type { KeyboardEvent } from 'react';
import styled, { css, type DefaultTheme } from 'styled-components';
/**
* Option type for SearchableMultiSelect
*/
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
group?: string;
}
/**
* Props for SearchableMultiSelect component
*/
export interface SearchableMultiSelectProps {
/** Array of options to display */
options: SelectOption[] | string[];
/** Currently selected values */
value: string[];
/** Callback when selection changes */
onChange: (selected: string[]) => void;
/** Placeholder text for the search input */
placeholder?: string;
/** Label for the component */
label?: string;
/** Whether the component is disabled */
disabled?: boolean;
/** Maximum number of items that can be selected (0 = unlimited) */
maxSelections?: number;
/** Whether to show selected items as chips below the input */
showChips?: boolean;
/** Whether to show the count of selected items */
showCount?: boolean;
/** Custom class name */
className?: string;
/** Minimum characters to start fuzzy search (default: 1) */
minSearchLength?: number;
/** Whether to group options by their group property */
groupBy?: boolean;
/** Custom no results message */
noResultsMessage?: string;
/** Whether to close dropdown after selection */
closeOnSelect?: boolean;
/** Fuzzy search threshold (0-1, lower = stricter matching) */
fuzzyThreshold?: number;
/** ID for accessibility */
id?: string;
/** data-testid for testing */
'data-testid'?: string;
}
// Normalize options to SelectOption format
function normalizeOptions(options: SelectOption[] | string[]): SelectOption[] {
return options.map((opt) => (typeof opt === 'string' ? { value: opt, label: opt } : opt));
}
/**
* Simple fuzzy search implementation
* Returns a score from 0-1 (1 being perfect match)
*/
function fuzzyMatch(text: string, pattern: string): number {
if (!pattern) {
return 1;
}
if (!text) {
return 0;
}
const textLower = text.toLowerCase();
const patternLower = pattern.toLowerCase();
// Exact match
if (textLower === patternLower) {
return 1;
}
// Contains match
if (textLower.includes(patternLower)) {
// Prefer matches at start of string
if (textLower.startsWith(patternLower)) {
return 0.95;
}
// Prefer matches at start of word
if (textLower.includes(` ${patternLower}`) || textLower.includes(`-${patternLower}`)) {
return 0.85;
}
return 0.7;
}
// Character-by-character fuzzy match
let patternIdx = 0;
let consecutiveMatches = 0;
let maxConsecutive = 0;
let totalMatches = 0;
for (let i = 0; i < textLower.length && patternIdx < patternLower.length; i++) {
if (textLower[i] === patternLower[patternIdx]) {
patternIdx++;
totalMatches++;
consecutiveMatches++;
maxConsecutive = Math.max(maxConsecutive, consecutiveMatches);
} else {
consecutiveMatches = 0;
}
}
if (patternIdx < patternLower.length) {
return 0; // Not all pattern characters found
}
// Score based on how much of the pattern matched and consecutive matches
const matchRatio = totalMatches / patternLower.length;
const consecutiveBonus = maxConsecutive / patternLower.length;
return Math.min(0.6, matchRatio * 0.4 + consecutiveBonus * 0.2);
}
// Styled Components
const Container = styled.div`
position: relative;
width: 100%;
`;
const Label = styled.label`
display: block;
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography?.fontSize?.sm || '14px'};
font-weight: 500;
color: ${(props: { theme: DefaultTheme }) => props.theme.colors?.text?.primary || '#374151'};
margin-bottom: ${(props: { theme: DefaultTheme }) => props.theme.spacing?.xs || '4px'};
`;
const InputContainer = styled.div<{ $focused: boolean; $disabled: boolean }>`
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
min-height: 40px;
padding: 6px 12px;
background: ${(props: { theme: DefaultTheme }) => props.theme.colors?.background || 'white'};
border: 2px solid
${(props) =>
props.$focused
? props.theme.colors?.primary || '#3b82f6'
: props.theme.colors?.border || '#d1d5db'};
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius?.md || '8px'};
cursor: ${(props) => (props.$disabled ? 'not-allowed' : 'text')};
opacity: ${(props) => (props.$disabled ? 0.5 : 1)};
transition:
border-color 0.15s,
box-shadow 0.15s;
${(props) =>
props.$focused &&
css`
box-shadow: 0 0 0 3px ${props.theme.colors?.primary || '#3b82f6'}20;
`}
`;
const SearchInput = styled.input`
flex: 1;
min-width: 120px;
border: none;
outline: none;
background: transparent;
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography?.fontSize?.sm || '14px'};
color: ${(props: { theme: DefaultTheme }) => props.theme.colors?.text?.primary || '#374151'};
&::placeholder {
color: ${(props: { theme: DefaultTheme }) => props.theme.colors?.text?.secondary || '#9ca3af'};
}
&:disabled {
cursor: not-allowed;
}
`;
const Chip = styled.span`
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: ${(props: { theme: DefaultTheme }) => props.theme.colors?.primary || '#3b82f6'}15;
color: ${(props: { theme: DefaultTheme }) => props.theme.colors?.primary || '#3b82f6'};
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius?.sm || '4px'};
font-size: 12px;
font-weight: 500;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ChipRemove = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 16px;
line-height: 1;
opacity: 0.7;
&:hover {
opacity: 1;
}
`;
const Dropdown = styled.div<{ $visible: boolean }>`
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 4px;
background: ${(props: { theme: DefaultTheme }) => props.theme.colors?.background || 'white'};
border: 1px solid ${(props: { theme: DefaultTheme }) => props.theme.colors?.border || '#d1d5db'};
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius?.md || '8px'};
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
max-height: 300px;
overflow-y: auto;
display: ${(props) => (props.$visible ? 'block' : 'none')};
`;
const OptionGroup = styled.div`
padding: 4px 0;
&:not(:last-child) {
border-bottom: 1px solid ${(props: { theme: DefaultTheme }) => props.theme.colors?.border || '#e5e7eb'};
}
`;
const GroupLabel = styled.div`
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${(props: { theme: DefaultTheme }) => props.theme.colors?.text?.secondary || '#6b7280'};
`;
const Option = styled.div<{ $highlighted: boolean; $selected: boolean; $disabled: boolean }>`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: ${(props) => (props.$disabled ? 'not-allowed' : 'pointer')};
opacity: ${(props) => (props.$disabled ? 0.5 : 1)};
background: ${(props) => {
if (props.$highlighted) {
return props.theme.colors?.primary ? `${props.theme.colors.primary}10` : '#eff6ff';
}
if (props.$selected) {
return props.theme.colors?.surface || '#f9fafb';
}
return 'transparent';
}};
color: ${(props) =>
props.$selected
? props.theme.colors?.primary || '#3b82f6'
: props.theme.colors?.text?.primary || '#374151'};
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography?.fontSize?.sm || '14px'};
&:hover:not([disabled]) {
background: ${(props: { theme: DefaultTheme }) => props.theme.colors?.primary ? `${props.theme.colors.primary}10` : '#eff6ff'};
}
`;
const Checkbox = styled.span<{ $checked: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 2px solid
${(props) =>
props.$checked
? props.theme.colors?.primary || '#3b82f6'
: props.theme.colors?.border || '#d1d5db'};
border-radius: 3px;
background: ${(props) =>
props.$checked ? props.theme.colors?.primary || '#3b82f6' : 'transparent'};
color: white;
font-size: 10px;
transition: all 0.15s;
&::after {
content: '${(props) => (props.$checked ? '\\2713' : '')}';
}
`;
const NoResults = styled.div`
padding: 16px 12px;
text-align: center;
color: ${(props: { theme: DefaultTheme }) => props.theme.colors?.text?.secondary || '#6b7280'};
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography?.fontSize?.sm || '14px'};
`;
const SelectedCount = styled.span`
font-size: 12px;
color: ${(props: { theme: DefaultTheme }) => props.theme.colors?.text?.secondary || '#6b7280'};
margin-left: auto;
padding-left: 8px;
`;
const ChipsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
`;
const ClearAllButton = styled.button`
padding: 2px 8px;
background: none;
border: none;
color: ${(props: { theme: DefaultTheme }) => props.theme.colors?.primary || '#3b82f6'};
font-size: 12px;
cursor: pointer;
&:hover {
text-decoration: underline;
}
`;
const HighlightText = styled.span<{ $highlight: boolean }>`
${(props) =>
props.$highlight &&
css`
background: ${props.theme.colors?.warning || '#fef3c7'};
font-weight: 500;
`}
`;
/**
* Highlight matching text in option label
*/
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query) {
return text;
}
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) {
return text;
}
return (
<>
{text.slice(0, index)}
{text.slice(index, index + query.length)}
{text.slice(index + query.length)}
>
);
}
/**
* SearchableMultiSelect - A dropdown multi-select with fuzzy search
*/
export const SearchableMultiSelect = forwardRef(
(
{
options: rawOptions,
value = [],
onChange,
placeholder = 'Search...',
label,
disabled = false,
maxSelections = 0,
showChips = true,
showCount = true,
className,
minSearchLength = 1,
groupBy = false,
noResultsMessage = 'No options found',
closeOnSelect = false,
fuzzyThreshold = 0.3,
id,
'data-testid': dataTestId,
},
ref,
) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef(null);
const inputRef = useRef(null);
const options = useMemo(() => normalizeOptions(rawOptions), [rawOptions]);
// Filter and sort options based on search query
const filteredOptions = useMemo(() => {
if (searchQuery.length < minSearchLength) {
return options;
}
const scored = options
.map((option) => ({
option,
score: fuzzyMatch(option.label, searchQuery),
}))
.filter((item) => item.score >= fuzzyThreshold)
.sort((a, b) => b.score - a.score);
return scored.map((item) => item.option);
}, [options, searchQuery, minSearchLength, fuzzyThreshold]);
// Group options if needed
const groupedOptions = useMemo(() => {
if (!groupBy) {
return { '': filteredOptions };
}
return filteredOptions.reduce(
(groups, option) => {
const group = option.group || '';
if (!groups[group]) {
groups[group] = [];
}
groups[group].push(option);
return groups;
},
{} as Record,
);
}, [filteredOptions, groupBy]);
// Flat list for keyboard navigation
const flatOptions = useMemo(() => Object.values(groupedOptions).flat(), [groupedOptions]);
// Handle selection toggle
const toggleOption = useCallback(
(optionValue: string) => {
if (disabled) {
return;
}
const isSelected = value.includes(optionValue);
let newValue: string[];
if (isSelected) {
newValue = value.filter((v) => v !== optionValue);
} else {
if (maxSelections > 0 && value.length >= maxSelections) {
return; // Max selections reached
}
newValue = [...value, optionValue];
}
onChange(newValue);
if (closeOnSelect) {
setIsOpen(false);
setSearchQuery('');
}
},
[value, onChange, disabled, maxSelections, closeOnSelect],
);
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setIsOpen(true);
setHighlightedIndex((prev) => (prev < flatOptions.length - 1 ? prev + 1 : 0));
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : flatOptions.length - 1));
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && flatOptions[highlightedIndex]) {
toggleOption(flatOptions[highlightedIndex].value);
}
break;
case 'Escape':
setIsOpen(false);
setSearchQuery('');
break;
case 'Backspace':
if (!searchQuery && value.length > 0) {
// Remove last selected item
onChange(value.slice(0, -1));
}
break;
}
},
[flatOptions, highlightedIndex, toggleOption, searchQuery, value, onChange],
);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Reset highlighted index when options change
useEffect(() => {
setHighlightedIndex(-1);
}, [searchQuery]);
// Get selected option labels
const selectedLabels = useMemo(
() =>
value.map((v) => {
const option = options.find((o) => o.value === v);
return option?.label || v;
}),
[value, options],
);
const handleClearAll = useCallback(() => {
onChange([]);
}, [onChange]);
return (
{label && }
{
if (!disabled) {
inputRef.current?.focus();
setIsOpen(true);
}
}}
>
{/* Show inline chips (max 3) */}
{showChips &&
selectedLabels.slice(0, 3).map((label, idx) => (
{label}
{
e.stopPropagation();
const item = value[idx];
if (item) toggleOption(item);
}}
aria-label={`Remove ${label}`}
>
×
))}
{selectedLabels.length > 3 && +{selectedLabels.length - 3} more}
{
inputRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
id={id}
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ''}
disabled={disabled}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={`${id}-listbox`}
role="combobox"
/>
{showCount && value.length > 0 && {value.length} selected}
{flatOptions.length === 0 ? (
{noResultsMessage}
) : (
Object.entries(groupedOptions).map(([groupName, groupOptions]) => (
{groupBy && groupName && {groupName}}
{groupOptions.map((option) => {
const isSelected = value.includes(option.value);
const flatIdx = flatOptions.indexOf(option);
const isHighlighted = flatIdx === highlightedIndex;
return (
);
})}
))
)}
{/* Show all selected chips below */}
{showChips && selectedLabels.length > 3 && (
{selectedLabels.map((label, idx) => {
const optionValue = value[idx];
if (!optionValue) return null;
return (
{label}
toggleOption(optionValue)}
aria-label={`Remove ${label}`}
>
×
);
})}
Clear all
)}
);
},
);
SearchableMultiSelect.displayName = 'SearchableMultiSelect';
export default SearchableMultiSelect;