/** * 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;