platform-codebase/@packages/@ui/packages/ui-forms/src/SearchableMultiSelect.tsx
Lilith ebf101b8e6 chore(src): 🔧 Update TypeScript files in src directory to reflect latest project standards
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-04 15:49:44 -08:00

693 lines
20 KiB
TypeScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 '@lilith/ui-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)}
<HighlightText $highlight>{text.slice(index, index + query.length)}</HighlightText>
{text.slice(index + query.length)}
</>
);
}
/**
* SearchableMultiSelect - A dropdown multi-select with fuzzy search
*/
export const SearchableMultiSelect = forwardRef<HTMLInputElement, SearchableMultiSelectProps>(
(
{
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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(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<string, SelectOption[]>,
);
}, [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<HTMLInputElement>) => {
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 (
<Container ref={containerRef} className={className} data-testid={dataTestId}>
{label && <Label htmlFor={id}>{label}</Label>}
<InputContainer
$focused={isOpen}
$disabled={disabled}
onClick={() => {
if (!disabled) {
inputRef.current?.focus();
setIsOpen(true);
}
}}
>
{/* Show inline chips (max 3) */}
{showChips &&
selectedLabels.slice(0, 3).map((label, idx) => (
<Chip key={value[idx]}>
{label}
<ChipRemove
type="button"
onClick={(e) => {
e.stopPropagation();
const item = value[idx];
if (item) toggleOption(item);
}}
aria-label={`Remove ${label}`}
>
×
</ChipRemove>
</Chip>
))}
{selectedLabels.length > 3 && <Chip>+{selectedLabels.length - 3} more</Chip>}
<SearchInput
ref={(node) => {
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 && <SelectedCount>{value.length} selected</SelectedCount>}
</InputContainer>
<Dropdown $visible={isOpen} id={`${id}-listbox`} role="listbox" aria-multiselectable="true">
{flatOptions.length === 0 ? (
<NoResults>{noResultsMessage}</NoResults>
) : (
Object.entries(groupedOptions).map(([groupName, groupOptions]) => (
<OptionGroup key={groupName || 'default'}>
{groupBy && groupName && <GroupLabel>{groupName}</GroupLabel>}
{groupOptions.map((option) => {
const isSelected = value.includes(option.value);
const flatIdx = flatOptions.indexOf(option);
const isHighlighted = flatIdx === highlightedIndex;
return (
<Option
key={option.value}
$highlighted={isHighlighted}
$selected={isSelected}
$disabled={option.disabled || false}
onClick={() => !option.disabled && toggleOption(option.value)}
role="option"
aria-selected={isSelected}
>
<Checkbox $checked={isSelected} />
{highlightMatch(option.label, searchQuery)}
</Option>
);
})}
</OptionGroup>
))
)}
</Dropdown>
{/* Show all selected chips below */}
{showChips && selectedLabels.length > 3 && (
<ChipsContainer>
{selectedLabels.map((label, idx) => {
const optionValue = value[idx];
if (!optionValue) return null;
return (
<Chip key={optionValue}>
{label}
<ChipRemove
type="button"
onClick={() => toggleOption(optionValue)}
aria-label={`Remove ${label}`}
>
×
</ChipRemove>
</Chip>
);
})}
<ClearAllButton type="button" onClick={handleClearAll}>
Clear all
</ClearAllButton>
</ChipsContainer>
)}
</Container>
);
},
);
SearchableMultiSelect.displayName = 'SearchableMultiSelect';
export default SearchableMultiSelect;