signoz/frontend/src/components/NewSelect/CustomMultiSelect.tsx
SagarRajput-7 2f8da5957b
feat: added custom single and multiselect components (#7497)
* feat: added new Select component for multi and single select

* feat: refactored code and added keyboard navigations in single select

* feat: different state handling in single select

* feat: updated the playground page

* feat: multi-select updates

* feat: fixed multiselect selection issues

* feat: multiselect cleanup

* feat: multiselect key navigation cleanup

* feat: added tokenization in multiselect

* feat: add on enter and handle duplicates

* feat: design update to the components

* feat: design update to the components

* feat: design update to the components

* feat: updated the playground page

* feat: edited playground data

* feat: edited styles

* feat: code cleanup

* feat: added shift + keys navigation and selection

* feat: improved styles and added darkmode styles

* feat: removed scroll bar hover style

* feat: added scroll bar on hover

* feat: added regex wrapper support

* feat: fixed right arrow navigation across chips

* feat: addressed all the single select feedbacks

* feat: addressed all the single select feedbacks

* feat: added only-all-toggle feat with ALL selection tag

* feat: remove clear, update footer info content and style and misc fixes

* feat: misc style fixes

* feat: added quotes exception to the multiselect tagging

* feat: removing demo page, and cleanup PR for reviews

* feat: resolved comments and refactoring

* feat: added test cases
2025-04-27 16:55:53 +05:30

1766 lines
48 KiB
TypeScript
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.

/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable no-nested-ternary */
/* eslint-disable react/function-component-definition */
import './styles.scss';
import {
DownOutlined,
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Checkbox, Select, Typography } from 'antd';
import cx from 'classnames';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { capitalize, isEmpty } from 'lodash-es';
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from 'lucide-react';
import type { BaseSelectRef } from 'rc-select';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { popupContainer } from 'utils/selectPopupContainer';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
filterOptionsBySearch,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
enum ToggleTagValue {
Only = 'Only',
All = 'All',
}
const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
placeholder = 'Search...',
className,
loading = false,
onSearch,
options = [],
value = [],
onChange,
defaultActiveFirstOption = true,
dropdownMatchSelectWidth = true,
noDataMessage,
errorMessage,
onClear,
enableAllSelection = true,
getPopupContainer,
dropdownRender,
highlightSearch = true,
popupClassName,
placement = 'bottomLeft',
maxTagCount,
allowClear = false,
onRetry,
maxTagTextLength,
...rest
}) => {
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const selectRef = useRef<BaseSelectRef>(null);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [activeChipIndex, setActiveChipIndex] = useState<number>(-1); // For tracking active chip/tag
const [selectionStart, setSelectionStart] = useState<number>(-1);
const [selectionEnd, setSelectionEnd] = useState<number>(-1);
const [selectedChips, setSelectedChips] = useState<number[]>([]);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
const isClickInsideDropdownRef = useRef(false);
// Convert single string value to array for consistency
const selectedValues = useMemo(
(): string[] =>
Array.isArray(value) ? value : value ? [value as string] : [],
[value],
);
// Helper function to get all *available* values from options (excluding disabled)
const getAllAvailableValues = useCallback(
(optionsList: OptionData[]): string[] => {
const values: string[] = [];
optionsList.forEach((option) => {
if ('options' in option && Array.isArray(option.options)) {
option.options?.forEach((subOption) => {
if (subOption.value) {
values.push(subOption.value);
}
});
} else if (option.value) {
values.push(option.value);
}
});
return values;
},
[],
);
const allAvailableValues = useMemo(() => {
const combinedOptions = prioritizeOrAddOptionForMultiSelect(
options,
selectedValues,
);
return getAllAvailableValues(combinedOptions);
}, [options, selectedValues, getAllAvailableValues]);
const isAllSelected = useMemo(() => {
if (!enableAllSelection || allAvailableValues.length === 0) {
return false;
}
// Check if every available value is included in the selected values
return allAvailableValues.every((val) => selectedValues.includes(val));
}, [selectedValues, allAvailableValues, enableAllSelection]);
// Value passed to the underlying Ant Select component
const displayValue = useMemo(
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
[isAllSelected, selectedValues],
);
// ===== Internal onChange Handler =====
const handleInternalChange = useCallback(
(newValue: string | string[]): void => {
// Ensure newValue is an array
const currentNewValue = Array.isArray(newValue) ? newValue : [];
if (!onChange) return;
// Case 1: Cleared (empty array or undefined)
if (!newValue || currentNewValue.length === 0) {
onChange([], []);
return;
}
// Case 2: "__all__" is selected (means select all actual values)
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
const allActualOptions = allAvailableValues.map(
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
);
onChange(allAvailableValues as any, allActualOptions as any);
} else {
// Case 3: Regular values selected
// Check if the selection now constitutes "all selected"
const nowAllSelected =
enableAllSelection &&
allAvailableValues.length > 0 &&
allAvailableValues.every((val) => currentNewValue.includes(val));
if (nowAllSelected) {
const allActualOptions = allAvailableValues.map(
(v) =>
options.flat().find((o) => o.value === v) || { label: v, value: v },
);
onChange(allAvailableValues as any, allActualOptions as any);
} else {
// Pass through the regular selection
// Map selected values back to OptionData format if possible
const correspondingOptions = currentNewValue.map(
(v) =>
options.flat().find((o) => o.value === v) || { label: v, value: v },
);
onChange(currentNewValue as any, correspondingOptions as any);
}
}
},
[onChange, allAvailableValues, options, enableAllSelection],
);
// ===== Existing Callbacks (potentially needing adjustment later) =====
const currentToggleTagValue = useCallback(
({ option }: { option: string }): ToggleTagValue => {
if (
Array.isArray(selectedValues) &&
selectedValues?.includes(option.toString()) &&
selectedValues.length === 1
) {
return ToggleTagValue.All;
}
return ToggleTagValue.Only;
},
[selectedValues],
);
const ensureValidOption = useCallback(
(option: string): boolean =>
!(
currentToggleTagValue({ option }) === ToggleTagValue.All &&
!enableAllSelection
),
[currentToggleTagValue, enableAllSelection],
);
/**
* Checks if a label exists in the provided options
*/
const isLabelPresent = useCallback(
(options: OptionData[], label: string): boolean =>
options.some((option) => {
const lowerLabel = label.toLowerCase();
// Check in nested options if they exist
if ('options' in option && Array.isArray(option.options)) {
return (
option.options?.some(
(subOption) => subOption.label.toLowerCase() === lowerLabel,
) || false
);
}
// Check top-level option
return option.label.toLowerCase() === lowerLabel;
}),
[],
);
/**
* Separates section and non-section options
*/
const splitOptions = useCallback((options: OptionData[]): {
sectionOptions: OptionData[];
nonSectionOptions: OptionData[];
} => {
const sectionOptions: OptionData[] = [];
const nonSectionOptions: OptionData[] = [];
options.forEach((option) => {
if ('options' in option && Array.isArray(option.options)) {
sectionOptions.push(option);
} else {
nonSectionOptions.push(option);
}
});
return { sectionOptions, nonSectionOptions };
}, []);
/**
* Apply search filtering to options
*/
const filteredOptions = useMemo(
(): OptionData[] => filterOptionsBySearch(options, searchText),
[options, searchText],
);
useEffect(() => {
if (!isEmpty(searchText)) {
setVisibleOptions([
{
label: searchText,
value: searchText,
type: 'custom',
},
...filteredOptions,
]);
} else {
setVisibleOptions(
selectedValues.length > 0 && isEmpty(searchText)
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
: filteredOptions,
);
}
}, [filteredOptions, searchText, options, selectedValues]);
// ===== Text Selection Utilities =====
/**
* Clears all chip selections
*/
const clearSelection = useCallback((): void => {
setSelectionStart(-1);
setSelectionEnd(-1);
setSelectedChips([]);
setIsSelectionMode(false);
}, []);
/**
* Selects all chips
*/
const selectAllChips = useCallback((): void => {
if (selectedValues.length === 0) return;
// When maxTagCount is set, only select visible chips
const visibleCount =
maxTagCount !== undefined && maxTagCount > 0
? Math.min(maxTagCount, selectedValues.length)
: selectedValues.length;
const allIndices = Array.from({ length: visibleCount }, (_, i) => i);
setSelectionStart(0);
setSelectionEnd(visibleCount - 1);
setSelectedChips(allIndices);
setIsSelectionMode(true);
}, [selectedValues, maxTagCount]);
/**
* Gets indices between start and end (inclusive)
*/
const getIndicesBetween = useCallback(
(start: number, end: number): number[] => {
const indices: number[] = [];
const min = Math.min(start, end);
const max = Math.max(start, end);
for (let i = min; i <= max; i++) {
indices.push(i);
}
return indices;
},
[],
);
/**
* Start selection from an index
*/
const startSelection = useCallback((index: number): void => {
setSelectionStart(index);
setSelectionEnd(index);
setSelectedChips([index]);
setIsSelectionMode(true);
setActiveChipIndex(index);
}, []);
/**
* Extend selection to an index
*/
const extendSelection = useCallback(
(index: number): void => {
if (selectionStart === -1) {
startSelection(index);
return;
}
setSelectionEnd(index);
const newSelectedChips = getIndicesBetween(selectionStart, index);
setSelectedChips(newSelectedChips);
setActiveChipIndex(index);
},
[selectionStart, getIndicesBetween, startSelection],
);
/**
* Handle copy event
*/
const handleCopy = useCallback((): void => {
if (selectedChips.length === 0) return;
const selectedTexts = selectedChips
.sort((a, b) => a - b)
.map((index) => selectedValues[index]);
const textToCopy = selectedTexts.join(', ');
navigator.clipboard.writeText(textToCopy).catch(console.error);
}, [selectedChips, selectedValues]);
/**
* Handle cut event
*/
const handleCut = useCallback((): void => {
if (selectedChips.length === 0) return;
// First copy the content
handleCopy();
// Then remove the selected chips
const newValues = selectedValues.filter(
(_, index) => !selectedChips.includes(index),
);
if (onChange) {
onChange(
newValues as any,
newValues.map((v) => ({ label: v, value: v })),
);
}
// Clear selection after cut
clearSelection();
}, [selectedChips, selectedValues, handleCopy, clearSelection, onChange]);
// ===== Event Handlers =====
/**
* Handles search input changes
*/
const handleSearch = useCallback(
(value: string): void => {
setActiveIndex(-1);
// Check if we have an unbalanced quote that needs to be preserved
const hasOpenQuote =
(value.match(/"/g) || []).length % 2 !== 0 ||
(value.match(/'/g) || []).length % 2 !== 0;
// Only process by comma if we don't have open quotes
if (value.includes(',') && !hasOpenQuote) {
const values: string[] = [];
let currentValue = '';
let inSingleQuotes = false;
let inDoubleQuotes = false;
for (let i = 0; i < value.length; i++) {
const char = value[i];
// Handle quote characters
if (char === '"' && !inSingleQuotes) {
inDoubleQuotes = !inDoubleQuotes;
currentValue += char;
} else if (char === "'" && !inDoubleQuotes) {
inSingleQuotes = !inSingleQuotes;
currentValue += char;
}
// Handle commas outside of quotes
else if (char === ',' && !inSingleQuotes && !inDoubleQuotes) {
// Comma outside quotes - end of value
if (currentValue.trim()) {
// Process the value to remove surrounding quotes if present
let processedValue = currentValue.trim();
if (
(processedValue.startsWith('"') && processedValue.endsWith('"')) ||
(processedValue.startsWith("'") && processedValue.endsWith("'"))
) {
// Remove surrounding quotes
processedValue = processedValue.substring(1, processedValue.length - 1);
}
if (!selectedValues.includes(processedValue)) {
values.push(processedValue);
}
}
currentValue = '';
}
// All other characters
else {
currentValue += char;
}
}
// Process the last value if there is one
if (currentValue.trim()) {
let processedValue = currentValue.trim();
if (
(processedValue.startsWith('"') && processedValue.endsWith('"')) ||
(processedValue.startsWith("'") && processedValue.endsWith("'"))
) {
// Remove surrounding quotes
processedValue = processedValue.substring(1, processedValue.length - 1);
}
if (!selectedValues.includes(processedValue)) {
values.push(processedValue);
}
}
if (values.length > 0) {
const newValues = [...selectedValues, ...values];
if (onChange) {
onChange(
newValues as any,
newValues.map((v) => ({ label: v, value: v })),
);
}
}
setSearchText('');
return;
}
if (value.endsWith(',') && !hasOpenQuote) {
// Process a single value when comma is typed at the end (outside quotes)
const valueToProcess = value.slice(0, -1).trim();
if (valueToProcess) {
// Process the value to remove surrounding quotes if present
let processedValue = valueToProcess;
if (
(processedValue.startsWith('"') && processedValue.endsWith('"')) ||
(processedValue.startsWith("'") && processedValue.endsWith("'"))
) {
// Remove surrounding quotes
processedValue = processedValue.substring(1, processedValue.length - 1);
}
if (!selectedValues.includes(processedValue)) {
const newValues = [...selectedValues, processedValue];
if (onChange) {
onChange(
newValues as any,
newValues.map((v) => ({ label: v, value: v })),
);
}
}
}
setSearchText('');
return;
}
// Normal single value handling
setSearchText(value.trim());
if (!isOpen) {
setIsOpen(true);
}
if (onSearch) onSearch(value.trim());
},
[onSearch, isOpen, selectedValues, onChange],
);
// ===== UI & Rendering Functions =====
/**
* Highlights matched text in search results
*/
const highlightMatchedText = useCallback(
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
},
[highlightSearch],
);
// Adjusted handleSelectAll for internal change handler
const handleSelectAll = useCallback((): void => {
if (!options) return;
if (isAllSelected) {
// If all are selected, deselect all
handleInternalChange([]);
} else {
// Otherwise, select all
handleInternalChange([ALL_SELECTED_VALUE]);
}
}, [options, isAllSelected, handleInternalChange]);
/**
* Renders an individual option
*/
const renderOptionItem = useCallback(
(
option: OptionData,
isSelected: boolean,
index?: number,
): React.ReactElement => {
const isActive = index === activeIndex;
const optionId = `option-${index}`;
const handleItemSelection = (source?: string): void => {
// Special handling for ALL option is done by the caller
if (!option.value) return;
if (source === 'option') {
if (
currentToggleTagValue({ option: option.value }) === ToggleTagValue.All
) {
handleSelectAll();
} else {
const newValues = [option.value];
if (onChange) {
onChange(
newValues,
newValues.map((v) => ({ label: v, value: v })),
);
}
}
return;
}
const newValues = selectedValues.includes(option.value)
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
if (onChange) {
onChange(
newValues,
newValues.map(
(v) => options.find((o) => o.value === v) ?? { label: v, value: v },
),
);
}
};
return (
<div
key={option.value || `option-${index}`}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleItemSelection('option');
setActiveChipIndex(-1);
setActiveIndex(-1);
}}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<Checkbox
checked={isSelected}
className="option-checkbox"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleItemSelection('checkbox');
setActiveChipIndex(-1);
setActiveIndex(-1);
}}
>
<div className="option-content">
<Typography.Text
ellipsis={{
tooltip: {
placement: 'right',
autoAdjustOverflow: true,
},
}}
className="option-label-text"
>
{highlightMatchedText(String(option.label || ''), searchText)}
</Typography.Text>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button type="text" className="only-btn">
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</Checkbox>
</div>
);
},
[
activeIndex,
highlightMatchedText,
searchText,
ensureValidOption,
currentToggleTagValue,
selectedValues,
onChange,
handleSelectAll,
options,
],
);
/**
* Helper function to render option with index tracking
*/
const renderOptionWithIndex = useCallback(
(option: OptionData, isSelected: boolean, idx: number) =>
renderOptionItem(option, isSelected, idx),
[renderOptionItem],
);
// Helper function to get visible chip indices
const getVisibleChipIndices = useCallback((): number[] => {
// If no values, return empty array
if (selectedValues.length === 0) return [];
// If maxTagCount is set and greater than 0, only return the first maxTagCount indices
const visibleCount =
maxTagCount !== undefined && maxTagCount > 0
? Math.min(maxTagCount, selectedValues.length)
: selectedValues.length;
return Array.from({ length: visibleCount }, (_, i) => i);
}, [selectedValues.length, maxTagCount]);
// Get the last visible chip index
const getLastVisibleChipIndex = useCallback((): number => {
const visibleIndices = getVisibleChipIndices();
return visibleIndices.length > 0
? visibleIndices[visibleIndices.length - 1]
: -1;
}, [getVisibleChipIndices]);
// Enhanced keyboard navigation with support for maxTagCount
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLElement>): void => {
// Get flattened list of all selectable options
const getFlatOptions = (): OptionData[] => {
if (!visibleOptions) return [];
const flatList: OptionData[] = [];
const hasAll = enableAllSelection && !searchText;
// Process options
const { sectionOptions, nonSectionOptions } = splitOptions(visibleOptions);
// Add all options to flat list
if (hasAll) {
flatList.push({
label: 'ALL',
value: '__all__', // Special value for the ALL option
type: 'defined',
});
}
// Add Regex to flat list
if (!isEmpty(searchText)) {
// Only add regex wrapper if it doesn't already look like a regex pattern
const isAlreadyRegex =
searchText.startsWith('.*') && searchText.endsWith('.*');
if (!isAlreadyRegex) {
flatList.push({
label: `.*${searchText}.*`,
value: `.*${searchText}.*`,
type: 'regex',
});
}
}
flatList.push(...nonSectionOptions);
sectionOptions.forEach((section) => {
if (section.options) {
flatList.push(...section.options);
}
});
return flatList;
};
const flatOptions = getFlatOptions();
// Get the active input element to check cursor position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
const cursorAtStart = isInputActive && activeElement?.selectionStart === 0;
const hasInputText = isInputActive && !!activeElement?.value;
// Get indices of visible chips
const visibleIndices = getVisibleChipIndices();
const lastVisibleChipIndex = getLastVisibleChipIndex();
// Handle special keyboard combinations
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
// Handle Ctrl+A (select all)
if (isCtrlOrCmd && e.key === 'a') {
e.preventDefault();
e.stopPropagation();
// If there are chips, select them all
if (selectedValues.length > 0) {
selectAllChips();
return;
}
// Otherwise let the default select all behavior happen
return;
}
// Handle copy/cut operations
if (isCtrlOrCmd && selectedChips.length > 0) {
if (e.key === 'c') {
e.preventDefault();
e.stopPropagation();
handleCopy();
return;
}
if (e.key === 'x') {
e.preventDefault();
e.stopPropagation();
handleCut();
return;
}
}
// Handle deletion of selected chips
if (
(e.key === 'Backspace' || e.key === 'Delete') &&
selectedChips.length > 0
) {
e.preventDefault();
e.stopPropagation();
// Remove all the selected chips
const newValues = selectedValues.filter(
(_, index) => !selectedChips.includes(index),
);
if (onChange) {
onChange(
newValues as any,
newValues.map((v) => ({ label: v, value: v })),
);
}
// Clear selection after deletion
clearSelection();
return;
}
// Handle selection with Shift + Arrow keys
if (e.shiftKey) {
// Only handle chip selection if we have chips and either a chip is active
// or we're at the start of an empty/unselected input
const canHandleChipSelection =
selectedValues.length > 0 &&
(activeChipIndex >= 0 || (cursorAtStart && !hasInputText));
if (canHandleChipSelection) {
switch (e.key) {
case 'ArrowLeft': {
e.preventDefault();
e.stopPropagation();
// Start selection if not in selection mode
if (!isSelectionMode) {
const start =
activeChipIndex >= 0 ? activeChipIndex : lastVisibleChipIndex;
// Start selection with current chip and immediate neighbor
// If we're starting from an active chip, select it and the one to its left
if (activeChipIndex >= 0 && activeChipIndex > 0) {
setSelectionStart(activeChipIndex);
setSelectionEnd(activeChipIndex - 1);
setSelectedChips(
getIndicesBetween(activeChipIndex, activeChipIndex - 1),
);
setIsSelectionMode(true);
setActiveChipIndex(activeChipIndex - 1);
} else {
// Fall back to single selection for edge cases
startSelection(start);
}
} else {
// Extend selection to the left
const newEnd = Math.max(0, selectionEnd - 1);
extendSelection(newEnd);
}
return;
}
case 'ArrowRight': {
e.preventDefault();
e.stopPropagation();
// Start selection if not in selection mode
if (!isSelectionMode) {
const start = activeChipIndex >= 0 ? activeChipIndex : 0;
// Start selection with current chip and immediate neighbor
// If we're starting from an active chip, select it and the one to its right
if (activeChipIndex >= 0 && activeChipIndex < lastVisibleChipIndex) {
setSelectionStart(activeChipIndex);
setSelectionEnd(activeChipIndex + 1);
setSelectedChips(
getIndicesBetween(activeChipIndex, activeChipIndex + 1),
);
setIsSelectionMode(true);
setActiveChipIndex(activeChipIndex + 1);
} else {
// Fall back to single selection for edge cases
startSelection(start);
}
}
// Extend selection to the right if not at last chip
else if (selectionEnd < lastVisibleChipIndex) {
const newEnd = selectionEnd + 1;
extendSelection(newEnd);
} else {
// Move focus to input when extending past last chip
clearSelection();
setActiveChipIndex(-1);
if (selectRef.current) {
selectRef.current.focus();
}
}
return;
}
case 'Home': {
e.preventDefault();
e.stopPropagation();
// Start or extend selection to beginning
if (!isSelectionMode) {
startSelection(0);
} else {
extendSelection(0);
}
return;
}
case 'End': {
e.preventDefault();
e.stopPropagation();
// Start or extend selection to end
if (!isSelectionMode) {
startSelection(lastVisibleChipIndex);
} else {
extendSelection(lastVisibleChipIndex);
}
return;
}
default:
break;
}
}
}
// If any key is pressed without shift/ctrl and we're in selection mode, clear selection
else if (isSelectionMode) {
// Don't clear selection on navigation keys
const isNavigationKey = [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Home',
'End',
].includes(e.key);
if (!isNavigationKey) {
clearSelection();
}
}
// Handle up/down keys to open dropdown from input
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !isOpen) {
e.stopPropagation();
e.preventDefault();
setIsOpen(true);
setActiveIndex(0);
setActiveChipIndex(-1);
clearSelection();
return;
}
// Handle chip navigation when active
if (activeChipIndex >= 0 && selectedValues.length > 0) {
// Prepare variables for potential use in multiple case blocks
let newValues: string[] = [];
switch (e.key) {
case 'ArrowLeft':
e.stopPropagation();
e.preventDefault();
// Only navigate within the visible chip range
if (activeChipIndex > 0 && visibleIndices.includes(activeChipIndex - 1)) {
// Move to previous visible chip
setActiveChipIndex(activeChipIndex - 1);
} else {
// Wrap around to last visible chip
setActiveChipIndex(lastVisibleChipIndex);
}
clearSelection();
break;
case 'ArrowRight':
e.stopPropagation();
e.preventDefault();
// Only navigate within the visible chip range
if (activeChipIndex < lastVisibleChipIndex) {
// Move to next visible chip
setActiveChipIndex(activeChipIndex + 1);
} else {
// Move from last chip to input
setActiveChipIndex(-1);
clearSelection();
if (selectRef.current) {
selectRef.current.focus();
}
}
clearSelection();
break;
case 'Backspace':
case 'Delete':
// Remove the active chip
e.stopPropagation();
e.preventDefault();
newValues = selectedValues.filter(
(_, index) => index !== activeChipIndex,
);
if (onChange) {
onChange(newValues as any, newValues as any);
}
// If we deleted the last chip, move focus to previous
if (activeChipIndex >= newValues.length) {
setActiveChipIndex(
newValues.length > 0
? Math.min(activeChipIndex - 1, lastVisibleChipIndex)
: -1,
);
}
clearSelection();
break;
case 'Escape':
// Clear chip selection
setActiveChipIndex(-1);
clearSelection();
break;
case 'ArrowDown':
case 'ArrowUp':
// Switch from chip to dropdown navigation
if (isOpen) {
setActiveChipIndex(-1);
setActiveIndex(0);
} else {
setIsOpen(true);
setActiveChipIndex(-1);
setActiveIndex(0);
}
clearSelection();
break;
default:
// If user types a letter when chip is active, focus the input field
if (e.key.length === 1 && /[a-zA-Z0-9]/.test(e.key)) {
e.stopPropagation();
e.preventDefault();
setActiveChipIndex(-1);
clearSelection();
// Try to focus on the input field
if (selectRef.current) {
// Focus select which will in turn focus the input
selectRef.current.focus();
}
}
break;
}
return; // Early return when navigating chips
}
// Handle dropdown navigation when open
if (isOpen) {
switch (e.key) {
case 'ArrowDown':
e.stopPropagation();
e.preventDefault();
setActiveIndex((prev) => (prev < flatOptions.length - 1 ? prev + 1 : 0));
setActiveChipIndex(-1);
clearSelection();
break;
case 'ArrowUp':
e.stopPropagation();
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : flatOptions.length - 1));
setActiveChipIndex(-1);
clearSelection();
break;
case 'Tab':
// Tab navigation with Shift key support
if (e.shiftKey) {
e.stopPropagation();
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : flatOptions.length - 1));
} else {
e.stopPropagation();
e.preventDefault();
setActiveIndex((prev) => (prev < flatOptions.length - 1 ? prev + 1 : 0));
}
setActiveChipIndex(-1);
clearSelection();
break;
case 'Enter':
e.stopPropagation();
e.preventDefault();
// If there's an active option in the dropdown, prioritize selecting it
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
const selectedOption = flatOptions[activeIndex];
if (selectedOption.value === '__all__') {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
? selectedValues.filter((v) => v !== selectedOption.value)
: [...selectedValues, selectedOption.value];
onChange(newValues as any, newValues as any);
}
} else if (searchText.trim()) {
const trimmedValue = searchText.trim();
// Check if value already exists in selectedValues
if (!selectedValues.includes(trimmedValue)) {
const newValues = [...selectedValues, trimmedValue];
if (onChange) {
onChange(
newValues as any,
newValues.map((v) => ({ label: v, value: v })),
);
}
}
setSearchText('');
}
break;
case 'Escape':
e.stopPropagation();
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
case SPACEKEY:
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
e.stopPropagation();
e.preventDefault();
const selectedOption = flatOptions[activeIndex];
// Check if it's the ALL option
if (selectedOption.value === '__all__') {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
? selectedValues.filter((v) => v !== selectedOption.value)
: [...selectedValues, selectedOption.value];
onChange(newValues as any, newValues as any);
}
// Don't close dropdown, just update selection
}
break;
case 'ArrowLeft':
// If at start of input, move to chips
if (cursorAtStart && visibleIndices.length > 0 && !hasInputText) {
e.stopPropagation();
e.preventDefault();
setActiveChipIndex(lastVisibleChipIndex);
setActiveIndex(-1);
}
break;
case 'ArrowRight':
// Start chip navigation when right arrow is pressed and we have chips to navigate
if (visibleIndices.length > 0 && !hasInputText) {
e.stopPropagation();
e.preventDefault();
// Navigate to the first chip
setActiveChipIndex(0);
setActiveIndex(-1);
}
break;
default:
break;
}
} else {
// Handle keyboard events when dropdown is closed
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp':
// Open dropdown when Down is pressed while closed
e.stopPropagation();
e.preventDefault();
setIsOpen(true);
setActiveIndex(0);
setActiveChipIndex(-1);
break;
case 'ArrowLeft':
// Start chip navigation if at start of input and no text or empty input
if ((cursorAtStart || !hasInputText) && visibleIndices.length > 0) {
e.stopPropagation();
e.preventDefault();
// Navigate to the last visible chip - this is what skips the "+N more" chip
setActiveChipIndex(lastVisibleChipIndex);
setActiveIndex(-1);
}
break;
case 'ArrowRight':
// No special handling needed for right arrow when dropdown is closed
break;
case 'Tab':
// When dropdown is closed and Tab is pressed, we should not capture it
// Let the browser handle the tab navigation
break;
case 'Backspace':
// If at start of input and no text, select last chip
if (cursorAtStart && !hasInputText && visibleIndices.length > 0) {
e.stopPropagation();
e.preventDefault();
setActiveChipIndex(lastVisibleChipIndex);
setActiveIndex(-1);
}
break;
case 'Escape':
// Clear focus when dropdown is closed
setActiveChipIndex(-1);
setActiveIndex(-1);
break;
default:
break;
}
}
},
[
selectedChips,
isSelectionMode,
isOpen,
activeChipIndex,
selectedValues,
visibleOptions,
enableAllSelection,
searchText,
splitOptions,
selectAllChips,
handleCopy,
handleCut,
onChange,
clearSelection,
getIndicesBetween,
startSelection,
selectionEnd,
extendSelection,
activeIndex,
handleSelectAll,
getVisibleChipIndices,
getLastVisibleChipIndex,
],
);
// Handle dropdown clicks
const handleDropdownClick = useCallback((e: React.MouseEvent): void => {
e.stopPropagation();
}, []);
// Add mousedown handler to the dropdown container
const handleDropdownMouseDown = useCallback((e: React.MouseEvent): void => {
e.preventDefault(); // Prevent focus change
isClickInsideDropdownRef.current = true;
}, []);
// Handle blur with the flag
const handleBlur = useCallback((): void => {
if (isClickInsideDropdownRef.current) {
isClickInsideDropdownRef.current = false;
return;
}
// Handle actual blur
setIsOpen(false);
}, []);
// Custom dropdown render with sections support
const customDropdownRender = useCallback((): React.ReactElement => {
// Process options based on current search
const processedOptions =
selectedValues.length > 0 && isEmpty(searchText)
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
: filteredOptions;
const { sectionOptions, nonSectionOptions } = splitOptions(processedOptions);
// Check if we need to add a custom option based on search text
const isSearchTextNotPresent =
!isEmpty(searchText) && !isLabelPresent(processedOptions, searchText);
// We will add these options in this order, which will be reflected in the UI
const customOptions: OptionData[] = [];
// add regex options first since they appear first in the UI
if (!isEmpty(searchText)) {
// Only add regex wrapper if it doesn't already look like a regex pattern
const isAlreadyRegex =
searchText.startsWith('.*') && searchText.endsWith('.*');
if (!isAlreadyRegex) {
customOptions.push({
label: `.*${searchText}.*`,
value: `.*${searchText}.*`,
type: 'regex',
});
}
}
// add custom option next
if (isSearchTextNotPresent) {
customOptions.push({
label: searchText,
value: searchText,
type: 'custom',
});
}
// Now add all custom options at the beginning
const enhancedNonSectionOptions = [...customOptions, ...nonSectionOptions];
const allOptionValues = getAllAvailableValues(processedOptions);
const allOptionsSelected =
allOptionValues.length > 0 &&
allOptionValues.every((val) => selectedValues.includes(val));
// Determine if ALL option should be shown
const showAllOption = enableAllSelection && !searchText;
// Initialize optionIndex based on whether the ALL option is shown
// If ALL option is shown, it gets index 0, and other options start at index 1
let optionIndex = showAllOption ? 1 : 0;
// Helper function to map options with index tracking
const mapOptions = (options: OptionData[]): React.ReactNode =>
options.map((option) => {
const optionValue = option.value || '';
const result = renderOptionWithIndex(
option,
selectedValues.includes(optionValue),
optionIndex,
);
optionIndex += 1;
return result;
});
const customMenu = (
<div
ref={dropdownRef}
className="custom-multiselect-dropdown"
onMouseDown={handleDropdownMouseDown}
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
role="listbox"
aria-multiselectable="true"
aria-activedescendant={
activeIndex >= 0 ? `option-${activeIndex}` : undefined
}
tabIndex={-1}
>
{/* ALL checkbox only when search is empty */}
{showAllOption && (
<>
<div
className={cx('option-item all-option', {
selected: allOptionsSelected,
active: activeIndex === 0,
})}
key={`all-option-${allOptionsSelected}`}
id="option-0"
onMouseEnter={(): void => {
setActiveIndex(0);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={allOptionsSelected}
tabIndex={0}
ref={(el): void => {
optionRefs.current[0] = el;
}}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleSelectAll();
}}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && activeIndex === 0) {
e.stopPropagation();
e.preventDefault();
handleSelectAll();
}
}}
>
<Checkbox
checked={allOptionsSelected}
style={{ width: '100%', height: '100%' }}
>
<div className="option-content">
<div>ALL</div>
</div>
</Checkbox>
</div>
<div className="divider" />
</>
)}
{/* Non-section options when not searching */}
{enhancedNonSectionOptions.length > 0 && (
<div className="no-section-options">
{mapOptions(enhancedNonSectionOptions)}
</div>
)}
{/* Section options when not searching */}
{sectionOptions.length > 0 &&
sectionOptions.map((section) =>
!isEmpty(section.options) ? (
<div className="select-group" key={section.label}>
<div className="group-label" role="heading" aria-level={2}>
{section.label}
</div>
<div role="group" aria-label={`${section.label} options`}>
{section.options && mapOptions(section.options)}
</div>
</div>
) : null,
)}
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text">We are updating the values...</div>
</div>
)}
{errorMessage && !loading && (
<div className="navigation-error">
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
</div>
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
if (onRetry) onRetry();
}}
/>
</div>
</div>
)}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
</div>
</div>
);
return dropdownRender ? dropdownRender(customMenu) : customMenu;
}, [
selectedValues,
searchText,
filteredOptions,
splitOptions,
isLabelPresent,
getAllAvailableValues,
enableAllSelection,
handleDropdownMouseDown,
handleDropdownClick,
handleKeyDown,
handleBlur,
activeIndex,
loading,
errorMessage,
noDataMessage,
dropdownRender,
renderOptionWithIndex,
handleSelectAll,
onRetry,
]);
// ===== Side Effects =====
// Clear search when dropdown closes
useEffect(() => {
if (!isOpen) {
setSearchText('');
setActiveIndex(-1);
// Don't clear activeChipIndex when dropdown closes to maintain tag focus
} else {
// When opening dropdown, clear chip selection
setActiveChipIndex(-1);
}
}, [isOpen]);
// Auto-scroll active option into view
useEffect(() => {
if (isOpen && activeIndex >= 0 && optionRefs.current[activeIndex]) {
optionRefs.current[activeIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, [isOpen, activeIndex]);
// Add document level event listeners to handle clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
// Find the select element by its class
const selectElement = document.querySelector('.custom-multiselect');
// If we're in selection mode and the click is outside the component, clear selection
if (
isSelectionMode &&
selectElement &&
!selectElement.contains(e.target as Node)
) {
clearSelection();
}
};
const handleKeyDown = (e: KeyboardEvent): void => {
// Clear selection when Escape is pressed
if (e.key === 'Escape' && isSelectionMode) {
clearSelection();
}
};
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return (): void => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isSelectionMode, clearSelection]);
// ===== Final Processing =====
// Custom Tag Render (needs significant updates)
const tagRender = useCallback(
(props: CustomTagProps): React.ReactElement => {
const { label, value, closable, onClose } = props;
// If the display value is the special ALL value, render the ALL tag
if (value === ALL_SELECTED_VALUE && isAllSelected) {
const handleAllTagClose = (
e: React.MouseEvent | React.KeyboardEvent,
): void => {
e.stopPropagation();
e.preventDefault();
handleInternalChange([]); // Clear selection when ALL tag is closed
};
const handleAllTagKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
handleAllTagClose(e);
}
// Prevent Backspace/Delete propagation if needed, handle in main keydown handler
};
return (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': activeChipIndex === 0, // Treat ALL tag as index 0 when active
'ant-select-selection-item-selected': selectedChips.includes(0),
})}
style={
activeChipIndex === 0 || selectedChips.includes(0)
? {
borderColor: Color.BG_ROBIN_500,
backgroundColor: Color.BG_SLATE_400,
}
: undefined
}
>
<span className="ant-select-selection-item-content">ALL</span>
{closable && (
<span
className="ant-select-selection-item-remove"
onClick={handleAllTagClose}
onKeyDown={handleAllTagKeyDown}
role="button"
tabIndex={0}
aria-label="Remove ALL tag (deselect all)"
>
×
</span>
)}
</div>
);
}
// If not isAllSelected, render individual tags using previous logic
// but base indices/visibility on the original `selectedValues`
if (!isAllSelected) {
const index = selectedValues.indexOf(value);
if (index === -1) return <div style={{ display: 'none' }} />; // Should not happen if value comes from displayValue
const isActive = index === activeChipIndex;
const isSelected = selectedChips.includes(index);
const isPlusNTag =
typeof value === 'string' &&
value.startsWith('+') &&
!selectedValues.includes(value);
if (isPlusNTag) {
// Render the "+N more" tag as before
return (
<div className="ant-select-selection-item">
<span className="ant-select-selection-item-content">{label}</span>
</div>
);
}
// Check visibility based on original selectedValues length and maxTagCount
const visibleCount =
maxTagCount !== undefined && maxTagCount > 0
? Math.min(maxTagCount, selectedValues.length)
: selectedValues.length;
const isVisible = index < visibleCount;
if (!isVisible) {
return <div style={{ display: 'none' }} />;
}
const handleTagKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.stopPropagation();
e.preventDefault();
onClose(); // Default close action removes the specific tag
}
};
return (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': isActive,
'ant-select-selection-item-selected': isSelected,
})}
style={
isActive || isSelected
? {
borderColor: Color.BG_ROBIN_500,
backgroundColor: Color.BG_SLATE_400,
}
: undefined
}
>
<span className="ant-select-selection-item-content">{label}</span>
{closable && (
<span
className="ant-select-selection-item-remove"
onClick={onClose} // Default Ant close handler
onKeyDown={handleTagKeyDown}
role="button"
tabIndex={0}
aria-label={`Remove tag ${label}`}
>
×
</span>
)}
</div>
);
}
// Fallback for safety, should not be reached
return <div />;
},
[
isAllSelected,
handleInternalChange,
activeChipIndex,
selectedChips,
selectedValues,
maxTagCount,
],
);
// ===== Component Rendering =====
return (
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={handleInternalChange}
onClear={(): void => handleInternalChange([])}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? 1 : maxTagCount}
{...rest}
/>
);
};
export default CustomMultiSelect;