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
This commit is contained in:
SagarRajput-7 2025-04-27 16:55:53 +05:30 committed by GitHub
parent 3f6f77d0e2
commit 2f8da5957b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 3894 additions and 0 deletions

View File

@ -0,0 +1,13 @@
.custom-multiselect-dropdown {
.divider {
height: 1px;
background-color: #e8e8e8;
margin: 4px 0;
}
.all-option {
font-weight: 500;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,606 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/function-component-definition */
import './styles.scss';
import {
CloseOutlined,
DownOutlined,
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Select } from 'antd';
import cx from 'classnames';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { capitalize, isEmpty } from 'lodash-es';
import { ArrowDown, 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 { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
prioritizeOrAddOptionForSingleSelect,
SPACEKEY,
} from './utils';
/**
* CustomSelect Component
*
*/
const CustomSelect: React.FC<CustomSelectProps> = ({
placeholder = 'Search...',
className,
loading = false,
onSearch,
options = [],
value,
onChange,
defaultActiveFirstOption = true,
noDataMessage,
onClear,
getPopupContainer,
dropdownRender,
highlightSearch = true,
placement = 'bottomLeft',
popupMatchSelectWidth = true,
popupClassName,
errorMessage,
allowClear = false,
onRetry,
...rest
}) => {
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
// Refs for element access and scroll behavior
const selectRef = useRef<BaseSelectRef>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
// ===== Option Filtering & Processing Utilities =====
/**
* 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,
);
}
// 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],
);
// ===== 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})`, 'gi'));
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
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],
);
/**
* Renders an individual option with proper keyboard navigation support
*/
const renderOptionItem = useCallback(
(
option: OptionData,
isSelected: boolean,
index?: number,
): React.ReactElement => {
const handleSelection = (): void => {
if (onChange) {
onChange(option.value, option);
setIsOpen(false);
}
};
const isActive = index === activeOptionIndex;
const optionId = `option-${index}`;
return (
<div
key={option.value}
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();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
);
},
[highlightMatchedText, searchText, onChange, activeOptionIndex],
);
/**
* Helper function to render option with index tracking
*/
const renderOptionWithIndex = useCallback(
(option: OptionData, isSelected: boolean, idx: number) =>
renderOptionItem(option, isSelected, idx),
[renderOptionItem],
);
/**
* Custom clear button renderer
*/
const clearIcon = useCallback(
() => (
<CloseOutlined
onClick={(e): void => {
e.stopPropagation();
if (onChange) onChange(undefined, []);
if (onClear) onClear();
}}
/>
),
[onChange, onClear],
);
// ===== Event Handlers =====
/**
* Handles search input changes
*/
const handleSearch = useCallback(
(value: string): void => {
const trimmedValue = value.trim();
setSearchText(trimmedValue);
if (onSearch) onSearch(trimmedValue);
},
[onSearch],
);
/**
* Prevents event propagation for dropdown clicks
*/
const handleDropdownClick = useCallback((e: React.MouseEvent): void => {
e.stopPropagation();
}, []);
/**
* Comprehensive keyboard navigation handler
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent): void => {
// Handle keyboard navigation when dropdown is open
if (isOpen) {
// Get flattened list of all selectable options
const getFlatOptions = (): OptionData[] => {
if (!filteredOptions) return [];
const flatList: OptionData[] = [];
// Process options
const { sectionOptions, nonSectionOptions } = splitOptions(
isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
);
// Add custom option if needed
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
flatList.push({
label: searchText,
value: searchText,
type: 'custom',
});
}
// Add all options to flat list
flatList.push(...nonSectionOptions);
sectionOptions.forEach((section) => {
if (section.options) {
flatList.push(...section.options);
}
});
return flatList;
};
const options = getFlatOptions();
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
break;
case 'ArrowUp':
e.preventDefault();
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
break;
case 'Tab':
// Tab navigation with Shift key support
if (e.shiftKey) {
e.preventDefault();
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
} else {
e.preventDefault();
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
break;
case 'Enter':
e.preventDefault();
if (activeOptionIndex >= 0 && activeOptionIndex < options.length) {
// Select the focused option
const selectedOption = options[activeOptionIndex];
if (onChange) {
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
}
} else if (!isEmpty(searchText)) {
// Add custom value when no option is focused
const customOption = {
label: searchText,
value: searchText,
type: 'custom',
};
if (onChange) {
onChange(customOption.value, customOption);
setIsOpen(false);
setActiveOptionIndex(-1);
}
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveOptionIndex(-1);
break;
case ' ': // Space key
if (activeOptionIndex >= 0 && activeOptionIndex < options.length) {
e.preventDefault();
const selectedOption = options[activeOptionIndex];
if (onChange) {
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
}
}
break;
default:
break;
}
} else if (e.key === 'ArrowDown' || e.key === 'Tab') {
// Open dropdown when Down or Tab is pressed while closed
e.preventDefault();
setIsOpen(true);
setActiveOptionIndex(0);
}
},
[
isOpen,
activeOptionIndex,
filteredOptions,
searchText,
onChange,
splitOptions,
value,
isLabelPresent,
],
);
// ===== Dropdown Rendering =====
/**
* Renders the custom dropdown with sections and keyboard navigation
*/
const customDropdownRender = useCallback((): React.ReactElement => {
// Process options based on current value
let processedOptions = isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
if (!isEmpty(searchText)) {
processedOptions = filterOptionsBySearch(processedOptions, searchText);
}
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);
let optionIndex = 0;
// Add custom option if needed
if (isSearchTextNotPresent) {
nonSectionOptions.unshift({
label: searchText,
value: searchText,
type: 'custom',
});
}
// Helper function to map options with index tracking
const mapOptions = (options: OptionData[]): React.ReactNode =>
options.map((option) => {
const result = renderOptionWithIndex(
option,
option.value === value,
optionIndex,
);
optionIndex += 1;
return result;
});
const customMenu = (
<div
ref={dropdownRef}
className="custom-select-dropdown"
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
role="listbox"
tabIndex={-1}
aria-activedescendant={
activeOptionIndex >= 0 ? `option-${activeOptionIndex}` : undefined
}
>
{/* Non-section options */}
<div className="no-section-options">
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
</div>
{/* Section options */}
{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" />
<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;
}, [
value,
filteredOptions,
searchText,
splitOptions,
isLabelPresent,
handleDropdownClick,
handleKeyDown,
activeOptionIndex,
loading,
errorMessage,
noDataMessage,
dropdownRender,
renderOptionWithIndex,
onRetry,
]);
// ===== Side Effects =====
// Clear search text when dropdown closes
useEffect(() => {
if (!isOpen) {
setSearchText('');
setActiveOptionIndex(-1);
}
}, [isOpen]);
// Auto-scroll to active option for keyboard navigation
useEffect(() => {
if (
isOpen &&
activeOptionIndex >= 0 &&
optionRefs.current[activeOptionIndex]
) {
optionRefs.current[activeOptionIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, [isOpen, activeOptionIndex]);
// ===== Final Processing =====
// Apply highlight to matched text in options
const optionsWithHighlight = useMemo(
() =>
options
?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
)
?.map((option) => ({
...option,
label: highlightMatchedText(String(option.label || ''), searchText),
})),
[options, searchText, highlightMatchedText],
);
// ===== Component Rendering =====
return (
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
);
};
export default CustomSelect;

View File

@ -0,0 +1,263 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CustomMultiSelect from '../CustomMultiSelect';
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Mock options data
const mockOptions = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];
const mockGroupedOptions = [
{
label: 'Group 1',
options: [
{ label: 'Group 1 - Option 1', value: 'g1-option1' },
{ label: 'Group 1 - Option 2', value: 'g1-option2' },
],
},
{
label: 'Group 2',
options: [
{ label: 'Group 2 - Option 1', value: 'g2-option1' },
{ label: 'Group 2 - Option 2', value: 'g2-option2' },
],
},
];
describe('CustomMultiSelect Component', () => {
it('renders with placeholder', () => {
const handleChange = jest.fn();
render(
<CustomMultiSelect
placeholder="Select multiple options"
options={mockOptions}
onChange={handleChange}
/>,
);
// Check placeholder exists
const placeholderElement = screen.getByText('Select multiple options');
expect(placeholderElement).toBeInTheDocument();
});
it('opens dropdown when clicked', async () => {
const handleChange = jest.fn();
render(<CustomMultiSelect options={mockOptions} onChange={handleChange} />);
// Click to open the dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument(); // The ALL option
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
});
it('selects multiple options', async () => {
const handleChange = jest.fn();
// Start with option1 already selected
render(
<CustomMultiSelect
options={mockOptions}
onChange={handleChange}
value={['option1']}
/>,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
// Click on Option 3
const option3 = screen.getByText('Option 3');
fireEvent.click(option3);
// Verify onChange was called with the right values
expect(handleChange).toHaveBeenCalled();
});
it('selects ALL options when ALL is clicked', async () => {
const handleChange = jest.fn();
render(
<CustomMultiSelect
options={mockOptions}
onChange={handleChange}
enableAllSelection
/>,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
// Click on ALL option
const allOption = screen.getByText('ALL');
fireEvent.click(allOption);
// Verify onChange was called with all option values
expect(handleChange).toHaveBeenCalledWith(
['option1', 'option2', 'option3'],
expect.arrayContaining([
expect.objectContaining({ value: 'option1' }),
expect.objectContaining({ value: 'option2' }),
expect.objectContaining({ value: 'option3' }),
]),
);
});
it('displays selected options as tags', async () => {
render(
<CustomMultiSelect options={mockOptions} value={['option1', 'option2']} />,
);
// Check that option values are shown as tags (not labels)
expect(screen.getByText('option1')).toBeInTheDocument();
expect(screen.getByText('option2')).toBeInTheDocument();
});
it('removes a tag when clicked', async () => {
const handleChange = jest.fn();
render(
<CustomMultiSelect
options={mockOptions}
value={['option1', 'option2']}
onChange={handleChange}
/>,
);
// Find close button on Option 1 tag and click it
const closeButtons = document.querySelectorAll(
'.ant-select-selection-item-remove',
);
fireEvent.click(closeButtons[0]);
// Verify onChange was called with remaining option
expect(handleChange).toHaveBeenCalledWith(
['option2'],
expect.arrayContaining([expect.objectContaining({ value: 'option2' })]),
);
});
it('filters options when searching', async () => {
render(<CustomMultiSelect options={mockOptions} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Type into search box - get input directly
const inputElement = selectElement.querySelector('input');
if (inputElement) {
fireEvent.change(inputElement, { target: { value: '2' } });
}
// Wait for the dropdown filtering to happen
await waitFor(() => {
// Check that the dropdown is present
const dropdownElement = document.querySelector(
'.custom-multiselect-dropdown',
);
expect(dropdownElement).toBeInTheDocument();
// Verify Option 2 is visible in the dropdown
const options = document.querySelectorAll('.option-label-text');
let foundOption2 = false;
options.forEach((option) => {
const text = option.textContent || '';
if (text.includes('Option 2')) foundOption2 = true;
});
expect(foundOption2).toBe(true);
});
});
it('renders grouped options correctly', async () => {
render(<CustomMultiSelect options={mockGroupedOptions} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check group headers and options
await waitFor(() => {
expect(screen.getByText('Group 1')).toBeInTheDocument();
expect(screen.getByText('Group 2')).toBeInTheDocument();
expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument();
expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument();
expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument();
expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument();
});
});
it('shows loading state', () => {
render(<CustomMultiSelect options={mockOptions} loading />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check loading text is displayed
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
});
it('shows error message', () => {
render(
<CustomMultiSelect
options={mockOptions}
errorMessage="Test error message"
/>,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check error message is displayed
expect(screen.getByText('Test error message')).toBeInTheDocument();
});
it('shows no data message', () => {
render(<CustomMultiSelect options={[]} noDataMessage="No data available" />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check no data message is displayed
expect(screen.getByText('No data available')).toBeInTheDocument();
});
it('shows "ALL" tag when all options are selected', () => {
render(
<CustomMultiSelect
options={mockOptions}
value={['option1', 'option2', 'option3']}
maxTagCount={2}
/>,
);
// When all options are selected, component shows ALL tag instead
expect(screen.getByText('ALL')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,206 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CustomSelect from '../CustomSelect';
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Mock options data
const mockOptions = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];
const mockGroupedOptions = [
{
label: 'Group 1',
options: [
{ label: 'Group 1 - Option 1', value: 'g1-option1' },
{ label: 'Group 1 - Option 2', value: 'g1-option2' },
],
},
{
label: 'Group 2',
options: [
{ label: 'Group 2 - Option 1', value: 'g2-option1' },
{ label: 'Group 2 - Option 2', value: 'g2-option2' },
],
},
];
describe('CustomSelect Component', () => {
it('renders with placeholder and options', () => {
const handleChange = jest.fn();
render(
<CustomSelect
placeholder="Test placeholder"
options={mockOptions}
onChange={handleChange}
/>,
);
// Check placeholder exists in the DOM (not using getByPlaceholderText)
const placeholderElement = screen.getByText('Test placeholder');
expect(placeholderElement).toBeInTheDocument();
});
it('opens dropdown when clicked', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
// Click to open the dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
});
it('calls onChange when option is selected', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Click on an option
await waitFor(() => {
const option = screen.getByText('Option 2');
fireEvent.click(option);
});
// Check onChange was called with correct value
expect(handleChange).toHaveBeenCalledWith('option2', expect.anything());
});
it('filters options when searching', async () => {
render(<CustomSelect options={mockOptions} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Type into search box
fireEvent.change(selectElement, { target: { value: '2' } });
// Dropdown should only show Option 2
await waitFor(() => {
// Check that the dropdown is present
const dropdownElement = document.querySelector('.custom-select-dropdown');
expect(dropdownElement).toBeInTheDocument();
// Use a simple approach to verify filtering
const allOptionsInDropdown = document.querySelectorAll('.option-item');
let foundOption2 = false;
allOptionsInDropdown.forEach((option) => {
if (option.textContent?.includes('Option 2')) {
foundOption2 = true;
}
// Should not show Options 1 or 3
expect(option.textContent).not.toContain('Option 1');
expect(option.textContent).not.toContain('Option 3');
});
expect(foundOption2).toBe(true);
});
});
it('renders grouped options correctly', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockGroupedOptions} onChange={handleChange} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check group headers and options
await waitFor(() => {
expect(screen.getByText('Group 1')).toBeInTheDocument();
expect(screen.getByText('Group 2')).toBeInTheDocument();
expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument();
expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument();
expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument();
expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument();
});
});
it('shows loading state', () => {
render(<CustomSelect options={mockOptions} loading />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check loading text is displayed
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
});
it('shows error message', () => {
render(
<CustomSelect options={mockOptions} errorMessage="Test error message" />,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check error message is displayed
expect(screen.getByText('Test error message')).toBeInTheDocument();
});
it('shows no data message', () => {
render(<CustomSelect options={[]} noDataMessage="No data available" />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check no data message is displayed
expect(screen.getByText('No data available')).toBeInTheDocument();
});
it('supports keyboard navigation', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
// Open dropdown using keyboard
const selectElement = screen.getByRole('combobox');
fireEvent.focus(selectElement);
// Press down arrow to open dropdown
fireEvent.keyDown(selectElement, { key: 'ArrowDown' });
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('Option 1')).toBeInTheDocument();
});
});
it('handles selection via keyboard', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear then press Enter
await waitFor(() => {
expect(screen.getByText('Option 1')).toBeInTheDocument();
// Press Enter to select first option
fireEvent.keyDown(screen.getByText('Option 1'), { key: 'Enter' });
});
// Check onChange was called
expect(handleChange).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,8 @@
import type { CustomMultiSelectProps } from './CustomMultiSelect';
import CustomMultiSelect from './CustomMultiSelect';
import type { CustomSelectProps, OptionData } from './CustomSelect';
import CustomSelect from './CustomSelect';
export { CustomMultiSelect, CustomSelect };
export type { CustomMultiSelectProps, CustomSelectProps, OptionData };

View File

@ -0,0 +1,838 @@
// Main container styles
// make const of #2c3044
$custom-border-color: #2c3044;
.custom-select {
width: 100%;
position: relative;
&.ant-select-focused {
.ant-select-selector {
border-color: var(--bg-robin-500);
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
}
}
.ant-select-selection-placeholder {
color: rgba(192, 193, 195, 0.45);
}
// Base styles are for dark mode
.ant-select-selector {
background-color: var(--bg-ink-400);
border-color: var(--bg-slate-400);
}
.ant-select-clear {
background-color: var(--bg-ink-400);
color: rgba(192, 193, 195, 0.7);
}
}
// Keep chip styles ONLY in the multi-select
.custom-multiselect {
width: 100%;
position: relative;
.ant-select-selector {
max-height: 200px;
overflow: auto;
scrollbar-width: thin;
background-color: var(--bg-ink-400);
border-color: var(--bg-slate-400);
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: $custom-border-color;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background-color: var(--bg-slate-400);
}
}
&.ant-select-focused {
.ant-select-selector {
border-color: var(--bg-robin-500);
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
}
}
.ant-select-selection-placeholder {
color: rgba(192, 193, 195, 0.45);
}
// Customize tags in multiselect (dark mode by default)
.ant-select-selection-item {
background-color: var(--bg-slate-400);
border-radius: 4px;
border: 1px solid $custom-border-color;
margin-right: 4px;
transition: all 0.2s;
color: var(--bg-vanilla-400);
// Style for active tag (keyboard navigation)
&-active {
border-color: var(--bg-robin-500) !important;
background-color: rgba(78, 116, 248, 0.15) !important;
outline: 2px solid rgba(78, 116, 248, 0.2);
}
// Style for selected tags (via keyboard or mouse selection)
&-selected {
border-color: var(--bg-robin-500) !important;
background-color: rgba(78, 116, 248, 0.15) !important;
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
}
.ant-select-selection-item-content {
color: var(--bg-vanilla-400);
}
.ant-select-selection-item-remove {
color: rgba(192, 193, 195, 0.7);
&:hover {
color: rgba(192, 193, 195, 1);
}
}
}
// Class applied when in selection mode
&.has-selection {
.ant-select-selection-item-selected {
cursor: move; // Indicate draggable
}
// Change cursor for selection
.ant-select-selector {
cursor: text;
}
}
}
// Dropdown styles
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
z-index: 1050 !important;
padding: 0;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.5), 0 6px 16px 0 rgba(0, 0, 0, 0.4),
0 9px 28px 8px rgba(0, 0, 0, 0.3);
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
.ant-select-item {
padding: 8px 12px;
color: var(--bg-vanilla-400);
// Make keyboard navigation visible
&-option-active {
background-color: var(--bg-slate-400) !important;
}
&-option-selected {
background-color: rgba(78, 116, 248, 0.15) !important;
}
}
}
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
resize: horizontal;
min-width: 300px !important;
.empty-message {
padding: 12px;
text-align: center;
color: rgba(192, 193, 195, 0.45);
}
}
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
width: 100%;
background-color: var(--bg-ink-400);
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: $custom-border-color;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background-color: var(--bg-slate-400);
}
.no-section-options {
margin-bottom: 8px;
}
.select-group {
margin-bottom: 16px;
border-radius: 4px;
overflow: hidden;
.group-label {
font-weight: 500;
padding: 4px 12px;
font-size: 13px;
color: var(--bg-vanilla-400);
background-color: var(--bg-slate-400);
border-bottom: 1px solid $custom-border-color;
border-top: 1px solid $custom-border-color;
position: relative;
z-index: 1;
margin-bottom: 4px;
}
}
.option-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--bg-vanilla-400);
&:hover {
background-color: var(--bg-slate-400);
}
&.selected {
background-color: rgba(78, 116, 248, 0.15);
font-weight: 500;
}
&.active {
background-color: rgba(78, 116, 248, 0.15);
border-color: var(--bg-robin-500);
}
.option-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.option-label-text {
margin-bottom: 0;
}
.option-badge {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background-color: $custom-border-color;
color: var(--bg-vanilla-400);
margin-left: 8px;
}
}
}
.loading-container {
display: flex;
justify-content: center;
padding: 12px;
}
}
.navigation-footer {
display: flex;
align-items: center;
padding: 8px 12px;
border-top: 1px solid var(--bg-slate-400);
position: sticky;
bottom: 0;
background-color: var(--bg-ink-400);
z-index: 1;
.navigation-icons {
display: flex;
margin-right: 8px;
color: var(--bg-vanilla-400);
}
.navigation-text {
color: var(--bg-vanilla-400);
font-size: 12px;
}
.navigation-error {
.navigation-text,
.navigation-icons {
color: var(--bg-cherry-500) !important;
}
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 4px;
}
.navigation-loading {
display: flex;
align-items: center;
gap: 8px;
.navigation-text,
.navigation-icons {
color: var(--bg-robin-600) !important;
}
}
.navigate {
display: flex;
align-items: center;
padding-right: 12px;
gap: 6px;
.icons {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--bg-ink-200);
border-right: 1.143px solid var(--bg-ink-200);
border-bottom: 2.286px solid var(--bg-ink-200);
border-left: 1.143px solid var(--bg-ink-200);
background: var(--Ink-400, var(--bg-ink-400));
}
}
}
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
width: 100%;
background-color: var(--bg-ink-400);
.select-all-option,
.custom-value-option {
padding: 8px 12px;
border-bottom: 1px solid $custom-border-color;
margin-bottom: 8px;
background-color: var(--bg-slate-400);
position: sticky;
top: 0;
z-index: 1;
}
.selected-values-section {
padding: 0 0 8px 0;
border-bottom: 1px solid $custom-border-color;
margin-bottom: 8px;
.selected-option {
padding: 4px 12px;
}
}
.select-group {
margin-bottom: 12px;
overflow: hidden;
.group-label {
font-weight: 500;
padding: 4px 12px;
font-size: 13px;
color: var(--bg-vanilla-400);
background-color: var(--bg-slate-400);
border-bottom: 1px solid $custom-border-color;
border-top: 1px solid $custom-border-color;
position: relative;
z-index: 1;
}
}
.option-item {
padding: 8px 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--bg-vanilla-400);
&.active {
background-color: rgba(78, 116, 248, 0.15);
border-color: var(--bg-robin-500);
}
&:hover {
background-color: var(--bg-slate-400);
}
&.selected {
background-color: rgba(78, 116, 248, 0.15);
font-weight: 500;
}
&.all-option {
font-weight: 500;
border-bottom: 1px solid $custom-border-color;
margin-bottom: 8px;
}
.option-checkbox {
width: 100%;
> span:not(.ant-checkbox) {
width: 100%;
}
.option-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.option-label-text {
margin-bottom: 0;
}
.option-badge {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background-color: $custom-border-color;
color: var(--bg-vanilla-400);
margin-left: 8px;
}
}
.only-btn {
display: none;
}
.toggle-btn {
display: none;
}
.only-btn:hover {
background-color: unset;
}
.toggle-btn:hover {
background-color: unset;
}
.option-content:hover {
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.toggle-btn {
display: none;
}
.option-badge {
display: none;
}
}
}
.option-checkbox:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.option-badge {
display: none;
}
}
}
.loading-container {
display: flex;
justify-content: center;
padding: 12px;
}
.empty-message {
padding: 12px;
text-align: center;
color: rgba(192, 193, 195, 0.45);
}
.status-message {
padding: 8px 12px;
text-align: center;
font-style: italic;
color: rgba(192, 193, 195, 0.65);
border-top: 1px dashed $custom-border-color;
}
}
// Custom styles for highlight text
.highlight-text {
background-color: rgba(78, 116, 248, 0.2);
padding: 0 1px;
border-radius: 2px;
font-weight: 500;
}
// Custom option styles for keyboard navigation
.custom-option {
&.focused,
&.ant-select-item-option-active {
background-color: var(--bg-slate-400) !important;
}
}
// Improve the sticky headers appearance
.custom-select-dropdown-container {
.group-label,
.ant-select-item-group {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--bg-slate-400);
border-bottom: 1px solid $custom-border-color;
padding: 4px 12px;
margin: 0;
width: 100%; // Ensure the header spans full width
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); // Add subtle shadow for separation
}
// Ensure proper spacing between sections
.select-group {
margin-bottom: 8px;
position: relative; // Create a positioning context
}
}
// Custom scrollbar styling (shared between components)
@mixin custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(192, 193, 195, 0.3) rgba(29, 33, 45, 0.6);
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background-color: rgba(29, 33, 45, 0.6);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(192, 193, 195, 0.3);
border-radius: 10px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(192, 193, 195, 0.5);
}
}
}
// Subtle nested scrollbar styling
@mixin nested-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(192, 193, 195, 0.2) rgba(29, 33, 45, 0.6);
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
background-color: rgba(29, 33, 45, 0.6);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(192, 193, 195, 0.2);
border-radius: 10px;
&:hover {
background-color: rgba(192, 193, 195, 0.3);
}
}
}
// Apply to main dropdown containers
.custom-select-dropdown,
.custom-multiselect-dropdown {
@include custom-scrollbar;
// Main content area
.options-container {
@include custom-scrollbar;
padding-right: 2px; // Add slight padding to prevent content touching scrollbar
}
// Non-sectioned options
.no-section-options {
@include nested-scrollbar;
margin-right: 2px;
padding-right: 2px;
}
}
// Apply to dropdown container wrappers
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
@include custom-scrollbar;
// Add subtle shadow inside to indicate scrollable area
&.has-overflow {
box-shadow: inset 0 -10px 10px -10px rgba(0, 0, 0, 0.2);
}
}
// Light Mode Overrides
.lightMode {
.custom-select {
.ant-select-selector {
background-color: var(--bg-vanilla-100);
border-color: #e9e9e9;
}
.ant-select-selection-placeholder {
color: rgba(0, 0, 0, 0.45);
}
.ant-select-clear {
background-color: var(--bg-vanilla-100);
color: rgba(0, 0, 0, 0.45);
}
&.ant-select-focused {
.ant-select-selector {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
}
}
.custom-multiselect {
.ant-select-selector {
background-color: var(--bg-vanilla-100);
border-color: #e9e9e9;
&::-webkit-scrollbar-thumb {
background-color: #ccc;
}
&::-webkit-scrollbar-track {
background-color: #f0f0f0;
}
}
.ant-select-selection-placeholder {
color: rgba(0, 0, 0, 0.45);
}
.ant-select-selection-item {
background-color: #f5f5f5;
border: 1px solid #e8e8e8;
color: rgba(0, 0, 0, 0.85);
.ant-select-selection-item-content {
color: rgba(0, 0, 0, 0.85);
}
.ant-select-selection-item-remove {
color: rgba(0, 0, 0, 0.45);
&:hover {
color: rgba(0, 0, 0, 0.85);
}
}
&-active {
border-color: var(--bg-robin-500) !important;
background-color: var(--bg-vanilla-300) !important;
}
&-selected {
border-color: #1890ff !important;
background-color: var(--bg-vanilla-300) !important;
}
}
}
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
background-color: var(--bg-vanilla-100);
border: 1px solid #f0f0f0;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
.empty-message {
color: rgba(0, 0, 0, 0.45);
}
.ant-select-item {
color: rgba(0, 0, 0, 0.85);
&-option-active {
background-color: #f5f5f5 !important;
}
&-option-selected {
background-color: var(--bg-vanilla-300) !important;
}
}
}
.custom-select-dropdown,
.custom-multiselect-dropdown {
border: 1px solid #f0f0f0;
background-color: var(--bg-vanilla-100);
&::-webkit-scrollbar-thumb {
background-color: #ccc;
}
&::-webkit-scrollbar-track {
background-color: #f0f0f0;
}
.select-group {
.group-label {
color: rgba(0, 0, 0, 0.85);
background-color: #fafafa;
border-bottom: 1px solid #f0f0f0;
border-top: 1px solid #f0f0f0;
}
}
.option-item {
color: rgba(0, 0, 0, 0.85);
&:hover {
background-color: #f5f5f5;
}
&.selected {
background-color: var(--bg-vanilla-300);
}
&.active {
background-color: var(--bg-vanilla-300);
border-color: #91d5ff;
}
.option-content {
.option-badge {
background-color: #f0f0f0;
color: #666;
}
}
}
}
.navigation-footer {
border-top: 1px solid #f0f0f0;
background-color: var(--bg-vanilla-100);
.navigation-icons {
color: rgba(0, 0, 0, 0.45);
}
.navigation-text {
color: rgba(0, 0, 0, 0.45);
}
.navigate {
.icons {
border-top: 1.143px solid var(--bg-ink-200);
border-right: 1.143px solid var(--bg-ink-200);
border-bottom: 2.286px solid var(--bg-ink-200);
border-left: 1.143px solid var(--bg-ink-200);
background: var(--bg-vanilla-300);
}
}
}
.custom-multiselect-dropdown {
.select-all-option,
.custom-value-option {
border-bottom: 1px solid #f0f0f0;
background-color: #fafafa;
}
.selected-values-section {
border-bottom: 1px solid #f0f0f0;
}
.status-message {
color: rgba(0, 0, 0, 0.65);
border-top: 1px dashed #f0f0f0;
}
.option-item {
&.all-option {
border-bottom: 1px solid #f0f0f0;
}
}
}
.highlight-text {
background-color: rgba(24, 144, 255, 0.2);
}
.custom-option {
&.focused,
&.ant-select-item-option-active {
background-color: #f5f5f5 !important;
}
}
.custom-select-dropdown-container {
.group-label,
.ant-select-item-group {
background-color: #f5f0f0;
border-bottom: 1px solid #e8e8e8;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
// Light mode scrollbar overrides
.custom-select-dropdown,
.custom-multiselect-dropdown,
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.05);
&::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.05);
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
&:hover {
background-color: rgba(0, 0, 0, 0.3);
}
}
}
}

View File

@ -0,0 +1,60 @@
import { SelectProps } from 'antd';
export interface OptionData {
label: string;
value?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
options?: OptionData[];
type?: 'defined' | 'custom' | 'regex';
}
export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
placeholder?: string;
className?: string;
loading?: boolean;
onSearch?: (value: string) => void;
options?: OptionData[];
defaultActiveFirstOption?: boolean;
noDataMessage?: string;
onClear?: () => void;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
highlightSearch?: boolean;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
popupMatchSelectWidth?: boolean;
errorMessage?: string;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
}
export interface CustomTagProps {
label: React.ReactNode;
value: string;
closable: boolean;
onClose: () => void;
}
export interface CustomMultiSelectProps
extends Omit<SelectProps<string[] | string>, 'options'> {
placeholder?: string;
className?: string;
loading?: boolean;
onSearch?: (value: string) => void;
options?: OptionData[];
defaultActiveFirstOption?: boolean;
dropdownMatchSelectWidth?: boolean | number;
noDataMessage?: string;
onClear?: () => void;
enableAllSelection?: boolean;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
highlightSearch?: boolean;
errorMessage?: string;
popupClassName?: string;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
maxTagCount?: number;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
}

View File

@ -0,0 +1,135 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { OptionData } from './types';
export const SPACEKEY = ' ';
export const prioritizeOrAddOptionForSingleSelect = (
options: OptionData[],
value: string,
label?: string,
): OptionData[] => {
let foundOption: OptionData | null = null;
// Separate the found option and the rest
const filteredOptions = options
.map((option) => {
if ('options' in option && Array.isArray(option.options)) {
// Filter out the value from nested options
const remainingSubOptions = option.options.filter(
(subOption) => subOption.value !== value,
);
const extractedOption = option.options.find(
(subOption) => subOption.value === value,
);
if (extractedOption) foundOption = extractedOption;
// Keep the group if it still has remaining options
return remainingSubOptions.length > 0
? { ...option, options: remainingSubOptions }
: null;
}
// Check top-level options
if (option.value === value) {
foundOption = option;
return null; // Remove it from the list
}
return option;
})
.filter(Boolean) as OptionData[]; // Remove null values
// If not found, create a new option
if (!foundOption) {
foundOption = { value, label: label ?? value };
}
// Add the found/new option at the top
return [foundOption, ...filteredOptions];
};
export const prioritizeOrAddOptionForMultiSelect = (
options: OptionData[],
values: string[], // Only supports multiple values (string[])
labels?: Record<string, string>,
): OptionData[] => {
const foundOptions: OptionData[] = [];
// Separate the found options and the rest
const filteredOptions = options
.map((option) => {
if ('options' in option && Array.isArray(option.options)) {
// Filter out selected values from nested options
const remainingSubOptions = option.options.filter(
(subOption) => subOption.value && !values.includes(subOption.value),
);
const extractedOptions = option.options.filter(
(subOption) => subOption.value && values.includes(subOption.value),
);
if (extractedOptions.length > 0) {
foundOptions.push(...extractedOptions);
}
// Keep the group if it still has remaining options
return remainingSubOptions.length > 0
? { ...option, options: remainingSubOptions }
: null;
}
// Check top-level options
if (option.value && values.includes(option.value)) {
foundOptions.push(option);
return null; // Remove it from the list
}
return option;
})
.filter(Boolean) as OptionData[]; // Remove null values
// Find missing values that were not present in the original options and create new ones
const missingValues = values.filter(
(value) => !foundOptions.some((opt) => opt.value === value),
);
const newOptions = missingValues.map((value) => ({
value,
label: labels?.[value] ?? value, // Use provided label or default to value
}));
// Add found & new options to the top
return [...newOptions, ...foundOptions, ...filteredOptions];
};
/**
* Filters options based on search text
*/
export const filterOptionsBySearch = (
options: OptionData[],
searchText: string,
): OptionData[] => {
if (!searchText.trim()) return options;
const lowerSearchText = searchText.toLowerCase();
return options
.map((option) => {
if ('options' in option && Array.isArray(option.options)) {
// Filter nested options
const filteredSubOptions = option.options.filter((subOption) =>
subOption.label.toLowerCase().includes(lowerSearchText),
);
return filteredSubOptions.length > 0
? { ...option, options: filteredSubOptions }
: undefined;
}
// Filter top-level options
return option.label.toLowerCase().includes(lowerSearchText)
? option
: undefined;
})
.filter(Boolean) as OptionData[];
};