mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-01 04:11:59 +08:00
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:
parent
3f6f77d0e2
commit
2f8da5957b
13
frontend/src/components/NewSelect/CustomMultiSelect.scss
Normal file
13
frontend/src/components/NewSelect/CustomMultiSelect.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
1765
frontend/src/components/NewSelect/CustomMultiSelect.tsx
Normal file
1765
frontend/src/components/NewSelect/CustomMultiSelect.tsx
Normal file
File diff suppressed because it is too large
Load Diff
606
frontend/src/components/NewSelect/CustomSelect.tsx
Normal file
606
frontend/src/components/NewSelect/CustomSelect.tsx
Normal 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;
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
206
frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx
Normal file
206
frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
8
frontend/src/components/NewSelect/index.ts
Normal file
8
frontend/src/components/NewSelect/index.ts
Normal 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 };
|
838
frontend/src/components/NewSelect/styles.scss
Normal file
838
frontend/src/components/NewSelect/styles.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
60
frontend/src/components/NewSelect/types.ts
Normal file
60
frontend/src/components/NewSelect/types.ts
Normal 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;
|
||||||
|
}
|
135
frontend/src/components/NewSelect/utils.ts
Normal file
135
frontend/src/components/NewSelect/utils.ts
Normal 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[];
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user