From 70f6ba863dde37320c235f7e60b88fa973045aae Mon Sep 17 00:00:00 2001 From: zzman <43282255+GermanVor@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:14:38 +0100 Subject: [PATCH] renderPopup for Select (#1312) --- src/components/Select/README.md | 87 ++++++++++++++++++ src/components/Select/Select.tsx | 91 ++++++++++++------- .../Select/__stories__/SelectShowcase.tsx | 30 +++++- .../Select/__tests__/Select.filter.test.tsx | 23 +++-- .../__tests__/Select.renderPopup.test.tsx | 57 ++++++++++++ .../Select/__tests__/Select.single.test.tsx | 28 ++++-- .../components/SelectFilter/SelectFilter.tsx | 2 + src/components/Select/constants.ts | 1 + src/components/Select/types.ts | 6 ++ 9 files changed, 270 insertions(+), 55 deletions(-) create mode 100644 src/components/Select/__tests__/Select.renderPopup.test.tsx diff --git a/src/components/Select/README.md b/src/components/Select/README.md index dafc459ac7..c630946149 100644 --- a/src/components/Select/README.md +++ b/src/components/Select/README.md @@ -974,6 +974,92 @@ const MyComponent = () => { +### Render custom popup + +To render custom popup use the `renderPopup` property. + + + + + +```tsx +import type {SelectProps} from '@gravity-ui/uikit'; + +const renderPopup: SelectProps['renderPopup'] = ({renderList, renderFilter}) => { + return ( + + {renderFilter()} +
+ {renderList()} + + ); +}; + +const MyComponent = () => { + return ( + + ); +}; +``` + + + ### Error The state of the `Select` in which you want to indicate incorrect user input. To change `Select` appearance, use the `validationState` property with the `"invalid"` value. An optional message text can be added via the `errorMessage` property. By default, message text is rendered outside the component. @@ -1028,6 +1114,7 @@ LANDING_BLOCK--> | [renderOption](#render-custom-options) | Used to render user options | `function` | | | renderOptionGroup | Used to render user option groups | `function` | | | [renderSelectedOption](#render-custom-selected-options) | Used to render user selected options | `function` | | +| [renderPopup](#render-custom-popup) | Used to render user popup content | `function` | | | [size](#size) | Control / options size | `string` | `'m'` | | value | Values that represent selected options | `string[]` | | | view | Control view | `string` | `'normal'` | diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index b08dca0a5c..143864e487 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -13,7 +13,7 @@ import {DEFAULT_VIRTUALIZATION_THRESHOLD, selectBlock} from './constants'; import {useQuickSearch} from './hooks'; import {initialState, reducer} from './store'; import {Option, OptionGroup} from './tech-components'; -import type {SelectProps} from './types'; +import type {SelectProps, SelectRenderPopup} from './types'; import type {SelectFilterRef} from './types-misc'; import { activateFirstClickableItem, @@ -34,6 +34,15 @@ type SelectComponent = (( p: SelectProps & {ref?: React.Ref}, ) => React.ReactElement) & {Option: typeof Option} & {OptionGroup: typeof OptionGroup}; +export const DEFAULT_RENDER_POPUP: SelectRenderPopup = ({renderFilter, renderList}) => { + return ( + + {renderFilter()} + {renderList()} + + ); +}; + export const Select = React.forwardRef(function Select( props: SelectProps, ref: React.Ref, @@ -48,6 +57,7 @@ export const Select = React.forwardRef(function renderOptionGroup, renderSelectedOption, renderEmptyOptions, + renderPopup = DEFAULT_RENDER_POPUP, getOptionHeight, getOptionGroupHeight, filterOption, @@ -245,6 +255,51 @@ export const Select = React.forwardRef(function ), }); + const _renderFilter = () => { + if (filterable) { + return ( + + ); + } + + return null; + }; + + const _renderList = () => { + if (filteredFlattenOptions.length || props.loading) { + return ( + + ); + } + + return ; + }; + return (
(function id={`select-popup-${selectId}`} placement={popupPlacement} > - {filterable && ( - - )} - {filteredFlattenOptions.length || props.loading ? ( - - ) : ( - - )} + {renderPopup({renderFilter: _renderFilter, renderList: _renderList})} {mode === Mode.VIEW ? ( - {children} ) : ( @@ -414,6 +410,30 @@ export const SelectShowcase = (props: SelectProps) => {
+ + { + return ( + +
{'---- Before Filter ----'}
+ {renderFilter()} +
{'---- After Filter, Before List ----'}
+ {renderList()} +
{'---- After List ----'}
+
+ ); + }, + }} + > + + + + +
); }; diff --git a/src/components/Select/__tests__/Select.filter.test.tsx b/src/components/Select/__tests__/Select.filter.test.tsx index da995d3a04..3ea9bc6cbc 100644 --- a/src/components/Select/__tests__/Select.filter.test.tsx +++ b/src/components/Select/__tests__/Select.filter.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import {cleanup} from '../../../../test-utils/utils'; import {TextInput} from '../../controls'; -import type {SelectOption, SelectProps} from '../types'; +import type {SelectOption, SelectProps, SelectRenderPopup} from '../types'; import {TEST_QA, generateOptions, generateOptionsGroups, setup} from './utils'; @@ -17,7 +17,7 @@ const onFilterChange = jest.fn(); const FILTER_PLACEHOLDER = 'Filter placeholder'; const EMPTY_OPTIONS_QA = 'empty-options'; -const renderCustomFilter: SelectProps['renderFilter'] = (props) => { +const RENDER_CUSTOM_FILTER: SelectProps['renderFilter'] = (props) => { const {value, ref, onChange, onKeyDown} = props; return ( @@ -31,17 +31,28 @@ const renderCustomFilter: SelectProps['renderFilter'] = (props) => { ); }; +const RENDER_POPUP: SelectRenderPopup = ({renderList, renderFilter}) => { + return ( + + {renderFilter()} + {renderList()} + + ); +}; + describe('Select filter', () => { - test.each<[string, Partial]>([ - ['default', {renderFilter: undefined}], - ['custom', {renderFilter: renderCustomFilter}], - ])('base functional with %s filter section', async (_, {renderFilter}) => { + test.each([ + ['default', undefined, undefined], + ['custom', RENDER_CUSTOM_FILTER, RENDER_POPUP], + ['custom', RENDER_CUSTOM_FILTER, undefined], + ])('base functional with %s filter section', async (_, renderFilter, renderPopup) => { const {getByTestId, getByPlaceholderText, getAllByRole, queryAllByRole} = setup({ options: generateOptions(40), filterPlaceholder: FILTER_PLACEHOLDER, filterable: true, onFilterChange, renderFilter, + renderPopup, }); const user = userEvent.setup(); const selectControl = getByTestId(TEST_QA); diff --git a/src/components/Select/__tests__/Select.renderPopup.test.tsx b/src/components/Select/__tests__/Select.renderPopup.test.tsx new file mode 100644 index 0000000000..482c8447a8 --- /dev/null +++ b/src/components/Select/__tests__/Select.renderPopup.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import userEvent from '@testing-library/user-event'; + +import {SelectQa} from '../constants'; + +import {DEFAULT_OPTIONS, TEST_QA, setup} from './utils'; + +const QA = 'SELECT_RENDER_POPUP_TEST_QA'; + +describe('Select renderPopup', () => { + test('default case', async () => { + const {getByTestId} = setup({ + options: DEFAULT_OPTIONS, + filterable: true, + renderPopup: ({renderFilter, renderList}) => { + return ( + + {renderFilter()} +
+ {renderList()} + + ); + }, + }); + + const user = userEvent.setup(); + const selectControl = getByTestId(TEST_QA); + // open select popup + await user.click(selectControl); + + const filterInput = getByTestId(SelectQa.FILTER_INPUT); + expect(filterInput).toBeVisible(); + + const list = getByTestId(SelectQa.LIST); + expect(list).toBeVisible(); + + const customPopupDiv = getByTestId(QA); + expect(customPopupDiv).toBeVisible(); + }); + + test('empty options', async () => { + const {getByTestId} = setup({ + options: [], + renderEmptyOptions: () =>
, + renderPopup: ({renderList}) => renderList(), + }); + + const user = userEvent.setup(); + const selectControl = getByTestId(TEST_QA); + // open select popup + await user.click(selectControl); + + const emptyContent = getByTestId(QA); + expect(emptyContent).toBeVisible(); + }); +}); diff --git a/src/components/Select/__tests__/Select.single.test.tsx b/src/components/Select/__tests__/Select.single.test.tsx index 04964bd784..9999d7cf7c 100644 --- a/src/components/Select/__tests__/Select.single.test.tsx +++ b/src/components/Select/__tests__/Select.single.test.tsx @@ -1,6 +1,7 @@ import userEvent from '@testing-library/user-event'; import {cleanup} from '../../../../test-utils/utils'; +import type {SelectRenderPopup} from '../types'; import { DEFAULT_OPTIONS, @@ -19,15 +20,18 @@ afterEach(() => { const onUpdate = jest.fn(); const onOpenChange = jest.fn(); const SELECTED_OPTION = DEFAULT_OPTIONS[0]; +const RENDER_POPUP: SelectRenderPopup = ({renderList}) => renderList(); describe('Select single mode actions', () => { describe('select option by click in', () => { test.each([ - [OptionsListType.FLAT, SELECTED_OPTION], - [OptionsListType.GROUPED, SELECTED_OPTION], - ])('%s list', async (type, selectedOption) => { + [OptionsListType.FLAT, SELECTED_OPTION, RENDER_POPUP], + [OptionsListType.GROUPED, SELECTED_OPTION, RENDER_POPUP], + [OptionsListType.FLAT, SELECTED_OPTION, undefined], + [OptionsListType.GROUPED, SELECTED_OPTION, undefined], + ])('%s list', async (type, selectedOption, renderPopup) => { const options = type === 'grouped' ? GROUPED_OPTIONS : DEFAULT_OPTIONS; - const {getByTestId, getByText} = setup({options, onUpdate, onOpenChange}); + const {getByTestId, getByText} = setup({options, onUpdate, onOpenChange, renderPopup}); const user = userEvent.setup(); const selectControl = getByTestId(TEST_QA); // open select popup @@ -43,13 +47,17 @@ describe('Select single mode actions', () => { describe('select option by', () => { test.each([ - ['Enter', OptionsListType.FLAT, SELECTED_OPTION], - ['Enter', OptionsListType.GROUPED, SELECTED_OPTION], - ['Space', OptionsListType.FLAT, SELECTED_OPTION], - ['Space', OptionsListType.GROUPED, SELECTED_OPTION], - ])('%s in %s list', async (key, type, selectedOption) => { + ['Enter', OptionsListType.FLAT, SELECTED_OPTION, RENDER_POPUP], + ['Enter', OptionsListType.GROUPED, SELECTED_OPTION, RENDER_POPUP], + ['Space', OptionsListType.FLAT, SELECTED_OPTION, RENDER_POPUP], + ['Space', OptionsListType.GROUPED, SELECTED_OPTION, RENDER_POPUP], + ['Enter', OptionsListType.FLAT, SELECTED_OPTION, undefined], + ['Enter', OptionsListType.GROUPED, SELECTED_OPTION, undefined], + ['Space', OptionsListType.FLAT, SELECTED_OPTION, undefined], + ['Space', OptionsListType.GROUPED, SELECTED_OPTION, undefined], + ])('%s in %s list', async (key, type, selectedOption, renderPopup) => { const options = type === 'grouped' ? GROUPED_OPTIONS : DEFAULT_OPTIONS; - const {getByTestId} = setup({options, onUpdate, onOpenChange}); + const {getByTestId} = setup({options, onUpdate, onOpenChange, renderPopup}); const user = userEvent.setup(); const selectControl = getByTestId(TEST_QA); await user.keyboard('[Tab]'); diff --git a/src/components/Select/components/SelectFilter/SelectFilter.tsx b/src/components/Select/components/SelectFilter/SelectFilter.tsx index 74e2beb99a..6923ce0b77 100644 --- a/src/components/Select/components/SelectFilter/SelectFilter.tsx +++ b/src/components/Select/components/SelectFilter/SelectFilter.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {TextInput} from '../../../controls'; import {block} from '../../../utils/cn'; +import {SelectQa} from '../../constants'; import type {SelectProps} from '../../types'; import type {SelectFilterRef} from '../../types-misc'; @@ -46,6 +47,7 @@ export const SelectFilter = React.forwardRef placeholder={placeholder} onUpdate={onChange} onKeyDown={onKeyDown} + qa={SelectQa.FILTER_INPUT} />
); diff --git a/src/components/Select/constants.ts b/src/components/Select/constants.ts index 7da7c3f704..abb183aad8 100644 --- a/src/components/Select/constants.ts +++ b/src/components/Select/constants.ts @@ -36,4 +36,5 @@ export const SelectQa = { POPUP: 'select-popup', SHEET: 'select-sheet', CLEAR: 'select-clear', + FILTER_INPUT: 'select-filter-input', }; diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts index 85710fcf5e..e568b030c9 100644 --- a/src/components/Select/types.ts +++ b/src/components/Select/types.ts @@ -44,6 +44,11 @@ export type SelectRenderOptionGroup = ( options: SelectRenderOptionViewParams, ) => React.ReactElement; +export type SelectRenderPopup = (popupItems: { + renderFilter: () => React.JSX.Element | null; + renderList: () => React.JSX.Element; +}) => React.ReactElement; + export type SelectSize = InputControlSize; export type SelectProps = QAProps & @@ -63,6 +68,7 @@ export type SelectProps = QAProps & renderOptionGroup?: SelectRenderOptionGroup; renderSelectedOption?: (option: SelectOption, index: number) => React.ReactElement; renderEmptyOptions?: ({filter}: {filter: string}) => React.ReactElement; + renderPopup?: SelectRenderPopup; getOptionHeight?: (option: SelectOption, index: number) => number; getOptionGroupHeight?: (option: SelectOptionGroup, index: number) => number; filterOption?: (option: SelectOption, filter: string) => boolean;