From e83a405d2f32cac70b3dfba770406f029d5ca729 Mon Sep 17 00:00:00 2001 From: Pablo Lozano Date: Tue, 21 Oct 2025 19:02:41 +0200 Subject: [PATCH] fix(select): Rewrite options list generators for single and multiple selectors by using render functions props --- package-lock.json | 1 + package.json | 3 +- src/lib/components/Select/Select.tsx | 197 +++++++++++++------------- src/lib/styles/Select/StyledSelect.ts | 28 ++-- src/stories/Select.stories.tsx | 41 +++--- tests/Select.test.tsx | 33 +---- 6 files changed, 145 insertions(+), 158 deletions(-) diff --git a/package-lock.json b/package-lock.json index f7f195de..7d68ffa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "antd": "5.11.5", "highcharts": "^11.3.0", "moment": "^2.30.1", + "rc-select": "^14.10.0", "react": "^18.2.0", "react-dom": "^18.2.0", "styled-components": "^6.1.11" diff --git a/package.json b/package.json index 2af92b04..123a25c1 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,8 @@ "moment": "^2.30.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "styled-components": "^6.1.11" + "styled-components": "^6.1.11", + "rc-select": "^14.10.0" }, "lint-staged": { "*.{ts,tsx,js,jsx,json,css,md}": [ diff --git a/src/lib/components/Select/Select.tsx b/src/lib/components/Select/Select.tsx index a380087e..1be3f6c5 100644 --- a/src/lib/components/Select/Select.tsx +++ b/src/lib/components/Select/Select.tsx @@ -8,6 +8,8 @@ import { Icon, Tooltip } from '@components'; import { withDataId } from '@components/DataId/withDataId'; import { SelectOptionStyle, StyledSelectDropdown, StyledSpanOption, StyledSpanOptionSelected } from '@styles/Select/StyledSelect'; import { colors } from 'index'; +import { FlattenOptionData } from 'rc-select/lib/interface'; +import { BaseOptionType } from 'rc-select/lib/Select'; import { filterOption, findSubstringIndices, getOptionsBySearch, getRegExpBasedOnInput } from './selectUtils'; import { ButtonPaginationSelector } from './ButtonPaginationSelector'; @@ -22,7 +24,7 @@ type CustomTagProps = { closable: boolean; }; -type Option = { +interface Option extends BaseOptionType { value: string | number; label: string; color?: string; @@ -136,71 +138,80 @@ const isDisabledOption = (option: Option, selectedValues: Array return option.disabled; }; -export const singleOptionsRenderer = (options: Option[], selectedValue: string | number | undefined, theme: Theme, dataId: string) => <> - {options.map((option) => ( - , info: { index: number }) => React.ReactNode; + +const baseRenderFunction: RendererFunction = (option) => {option.label}; + +const singleSelectRenderOptionGenerator = (selectedValues: Array, theme: Theme, renderOptionFn?: RendererFunction): RendererFunction => { + const render = renderOptionFn || baseRenderFunction; + return (option: FlattenOptionData - ))} - + {render(option, info)} + + ); + } + return ( + + {render(option, info)} + + ); + }; +}; -export const optionsRenderer = (options: Option[], selectedValues: Array, searchValue: string, theme: Theme, dataId: string, currentPage: number, pageSize?: number) => { - const startIndex = (currentPage - 1) * (pageSize ?? options.length); - const endIndex = startIndex + (pageSize ?? options.length); - let optionsToRender = searchValue === '' ? options : (getOptionsBySearch(options, searchValue) as Option[]); - optionsToRender = optionsToRender.slice(startIndex, endIndex); - return ( - <> - {optionsToRender.map((option) => { - const backgroundColor = selectedValues.includes(option.value) ? (option.color ? get(theme.color, option.color) : colors.gray400) : colors.white; - return ( - - {selectedValues.includes(option.value) ? ( - - {option.label} - - ) : ( - renderUnselectedOption(option.label, searchValue, dataId) - )} - - ); - })} - - ); +const multipleSelectRenderOptionGenerator = (selectedValues: Array, theme: Theme, renderOptionFn?: RendererFunction): RendererFunction => { + const render = renderOptionFn || baseRenderFunction; + + return (option, info) => { + const { data } = option; + const backgroundColor = selectedValues.includes(data.value) ? get(theme.color, data.color || '') || colors.gray400 : colors.white; + if (data.value && selectedValues.includes(data.value)) { + return ( + + {render(option, info)} + + ); + } + return ( + + {render(option, info)} + + ); + }; }; export type SelectTextProps = { @@ -271,14 +282,15 @@ export const Select = withDataId( const ref = useRef(null); const sValue = useRef(''); const th = useContext(ThemeContext) || defaultTheme; - const options = originalOptions || []; - + const options = (originalOptions || []).map((option) => ({ + disabled: isDisabledOption(option, selectedValues, pageSize), + ...option})); useEffect(() => { setCurrentPage(1); }, [searchValue]); useEffect(() => { - if (defaultValues ) { + if (defaultValues) { setSelectedValues(defaultValues); } }, [defaultValues]); @@ -315,6 +327,11 @@ export const Select = withDataId( setShowDropdown(false); }; + const optionRender = + mode === 'multiple' + ? multipleSelectRenderOptionGenerator(selectedValues, th, props.optionRender) + : singleSelectRenderOptionGenerator(selectedValues, th, props.optionRender); + return ( <> @@ -325,16 +342,10 @@ export const Select = withDataId( autoClearSearchValue data-id={dataId} defaultValue={defaultValues} - filterOption={(input: string, option?: any) => { - const opt = options.find(x => x.value === option.value) - if (opt && opt?.label) { - return (opt.label as string).toLowerCase().includes(input.toLowerCase()); - } - return false; - }} + optionRender={optionRender as (option: FlattenOptionData, info: { index: number }) => React.ReactNode} + options={options} loading={isLoading} placeholder={placeholder} - open={showDropdown} ref={(r) => { ref.current = r; }} @@ -364,9 +375,9 @@ export const Select = withDataId( if (showDropdown) { closeDropdown(); e.stopPropagation(); - } - else + } else { setShowDropdown(true); + } }} ariaLabel={showDropdown ? hideOptionsAriaLabel : showOptionsAriaLabel} /> @@ -395,33 +406,21 @@ export const Select = withDataId( aria-disabled={disabled} aria-expanded={showDropdown} {...props} - > - {singleOptionsRenderer(options, selectedValues.length > 0 ? selectedValues[0] : undefined, th, dataId)} - + /> ) : ( - dropdownRenderSelect( - menu, - currentPage, - options, - handleChangePage, - handleSelectAll, - text, - searchValue, - mode, - th, - pageSize - ) + ? (menu: ReactElement) => dropdownRenderSelect(menu, currentPage, options, handleChangePage, handleSelectAll, text, searchValue, mode, th, pageSize) : undefined } optionFilterProp='children' + optionRender={optionRender as (option: FlattenOptionData, info: { index: number }) => React.ReactNode} filterOption={filterOption} maxTagCount='responsive' maxTagPlaceholder={(displayValue: DisplayValue[]) => { @@ -459,9 +458,9 @@ export const Select = withDataId( if (showDropdown) { closeDropdown(); e.stopPropagation(); - } - else + } else { setShowDropdown(true); + } }} ariaLabel={showDropdown ? hideOptionsAriaLabel : showOptionsAriaLabel} /> @@ -480,7 +479,11 @@ export const Select = withDataId( } } }} - tagRender={maxTagLength ? (customTagProps: CustomTagProps) => tagRenderButtonPagination(customTagProps, options, maxTagLength, th, deleteOptionSelectedAriaLabel || '') : undefined} + tagRender={ + maxTagLength + ? (customTagProps: CustomTagProps) => tagRenderButtonPagination(customTagProps, options, maxTagLength, th, deleteOptionSelectedAriaLabel || '') + : undefined + } value={selectedValues} dropdownAlign={{ offset: [0, 3] }} onChange={(values, _options) => { @@ -505,9 +508,7 @@ export const Select = withDataId( aria-disabled={disabled} aria-expanded={showDropdown} {...props} - > - {optionsRenderer(options, selectedValues, searchValue, th, dataId, currentPage, pageSize)} - + /> )} ); diff --git a/src/lib/styles/Select/StyledSelect.ts b/src/lib/styles/Select/StyledSelect.ts index 0e035ced..529948db 100644 --- a/src/lib/styles/Select/StyledSelect.ts +++ b/src/lib/styles/Select/StyledSelect.ts @@ -74,20 +74,26 @@ export const StyledPaginationSelector = styled.div` export const StyledSelectDropdown = styled.div` .ant-select-item { min-height: auto; + background-color: transparent; } .ant-select-item-option-state { - display: flex; + position: relative; + left: -20px; align-items: center; padding-right: 8px; } + + .ant-select-item-option { + right: -26px; + } `; -const getSpanColor = (theme: Theme, _color: string) => get(theme.color, _color); +const getSpanColor = (theme: Theme, color: string) => theme.color[color] || color; -export const StyledSpanOptionSelected = styled.span<{ theme: Theme; icon?: any; closable?: any; color?: string, $isSingleSelect?: boolean }>` +export const StyledSpanOptionSelected = styled.span<{ value?: string; theme: Theme; icon?: any; closable?: any; color?: string; $isSingleSelect?: boolean }>` display: flex; align-items: center; - padding: 2px 4px; + padding: 2px 0px 2px 4px; font-weight: 400; white-space: nowrap; cursor: pointer; @@ -96,20 +102,20 @@ export const StyledSpanOptionSelected = styled.span<{ theme: Theme; icon?: any; margin: 0px; font-size: 14px; line-height: 14px; - background: ${(props) => !props.$isSingleSelect && props.color ? getSpanColor(props.theme, props.color) : (!props.$isSingleSelect && gray400(props.theme))}; - color: ${(props) => !props.$isSingleSelect ? white(props.theme) : false}; + background: ${(props) => (!props.$isSingleSelect && props.color ? getSpanColor(props.theme, props.color) : !props.$isSingleSelect && gray400(props.theme))}; + color: ${(props) => (!props.$isSingleSelect ? white(props.theme) : false)}; ${StyledIcon} { ${(props) => - props.icon && - css` + props.icon && + css` &.icon { margin-right: 3px; margin-left: -2px; } `}; ${(props) => - props.closable && - css` + props.closable && + css` &.icon-close { cursor: pointer; margin-right: -2px; @@ -120,7 +126,7 @@ export const StyledSpanOptionSelected = styled.span<{ theme: Theme; icon?: any; } `; -export const StyledSpanOption = styled.span<{ value: string | null }>` +export const StyledSpanOption = styled.span<{ value: string }>` align-items: center; padding: 2px 4px; font-weight: 400; diff --git a/src/stories/Select.stories.tsx b/src/stories/Select.stories.tsx index cb78c15d..822782bd 100644 --- a/src/stories/Select.stories.tsx +++ b/src/stories/Select.stories.tsx @@ -9,19 +9,17 @@ export default { const singleSelectOptions = [ { - label: manager, - title: 'manager', + label: 'manager', options: [ - { label: Jack, value: 'Jack' }, - { label: Lucy, value: 'Lucy' }, + { label: 'Jack', value: 1 }, + { label: 'Lucy', value: 2 }, ], }, { - label: engineer, - title: 'engineer', + label: 'engineer', options: [ - { label: Chloe, value: 'Chloe' }, - { label: Lucas, value: 'Lucas' }, + { label: 'Chloe', value: 3 }, + { label: 'Lucas', value: 4 }, ], }, ]; @@ -35,9 +33,10 @@ export const Multiple = () => ( ; +const optionRender = (option: any, info: { index: number }) => { + if (info.index % 2) { + return {option.label}; + } + return {option.label}; +}; export const SingleSelect = () => { - const [selectedValues, setSelectedValue] = useState(['4']); + const [selectedValues, setSelectedValues] = useState([singleSelectOptions[0].options[1].value]); return ( @@ -181,7 +186,7 @@ export const SingleSelect = () => { Based on the mode prop, the select can be single or multiple. - + @@ -189,7 +194,7 @@ export const SingleSelect = () => { }; export const SingleSelectAllowClear = () => { - const [selectedValues, setSelectedValue] = useState(['2']); + const [selectedValues, setSelectedValue] = useState(singleSelectOptions[0].options[1].value); const handleChange = (value: any) => setSelectedValue(value); return ( @@ -200,10 +205,10 @@ export const SingleSelectAllowClear = () => { { console.log('clear all'); - setSelectedValue([]); + setSelectedValue(null); }} onChange={handleChange} options={singleSelectOptions} diff --git a/tests/Select.test.tsx b/tests/Select.test.tsx index 480166d3..5346dbf6 100644 --- a/tests/Select.test.tsx +++ b/tests/Select.test.tsx @@ -50,34 +50,7 @@ describe('selectUtils', () => { expect(filterOption('***', options[0])).toEqual(true); expect(filterOption(' ', options[0])).toEqual(true); }); - // it('should return true if the option matches with the input value', () => { - // const result = filterOption('Test 1', { - // children: { props: { value: 'Test 1' } }, - // label: 'Test 1', - // }); - // expect(result).toEqual(['Test 1']); - // }); - // it('should return true if the option matches with the substring previous to *', () => { - // const result = filterOption('Random*', { - // children: { props: { value: 'Random Test 1' } }, - // label: 'Random Test 1', - // }); - // expect(result).toEqual(['Random']); - // }); - // it('should return true if the option matches with the substring after to *', () => { - // const result = filterOption('* 1', { - // children: { props: { value: 'Test 1' } }, - // label: 'Test 1', - // }); - // expect(result).toEqual([' 1']); - // }); - // it('should return true if the option matches with the substring before and after to *', () => { - // const result = filterOption('t* 1', { - // children: { props: { value: 'Test 1' } }, - // label: 'Test 1', - // }); - // expect(result).toEqual(['Test 1']); - // }); + }); }); @@ -446,7 +419,7 @@ describe('Select', () => { expect(screen.getAllByText(regex).length > 0); // When act(() => { - fireEvent.click(screen.getByTestId('select-option-Test1')); + fireEvent.click(screen.getByTestId('option-span-Test1')); }); // Then expect(onChange).toBeCalled(); @@ -483,7 +456,7 @@ describe('Select', () => { expect(screen.getAllByText(regex).length > 0); // When act(() => { - fireEvent.click(screen.getByTestId('select-option-Test1')); + fireEvent.click(screen.getByTestId('option-span-Test1')); }); // Then expect(onChange).toBeCalled();