693 lines
20 KiB
TypeScript
Executable file
693 lines
20 KiB
TypeScript
Executable file
/**
|
||
* 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;
|