Repository: gravity-ui/uikit
Pull request #828 (2023-07-19 09:01:18)

feat(Select): add  renderOptionGroup, getOptionGroupHeight props
Add prop getOptionGroupHeight in Select, cause no way to change it.

gravity-ui-bot: [Preview](https://preview.gravity-ui.com/uikit/828/) is ready.
<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/src/components/Select/README.md b/src/components/Select/README.md
index 855cf783..c348d2ac 100644
--- a/src/components/Select/README.md
+++ b/src/components/Select/README.md
@@ -7,9 +7,11 @@
 | [renderControl](#rendercontrol) | `function`                              | `-`             | Used to render user control                                                                                                   |
 | [renderFilter](#renderfilter)   | `function`                              | `-`             | Used to render user filter section                                                                                            |
 | renderOption                    | `function`                              | `-`             | Used to render user options                                                                                                   |
+| renderOptionGroup               | `function`                              | `-`             | Used to render user option groups                                                                                             |
 | renderSelectedOption            | `function`                              | `-`             | Used to render user selected options                                                                                          |
 | renderEmptyOptions              | `function`                              | `-`             | Used to render node for an empty options list                                                                                 |
 | getOptionHeight                 | `function`                              | `-`             | Used to set height of customized user options                                                                                 |
+| getOptionGroupHeight            | `function`                              | `-`             | Used to set height of customized user option group                                                                            |
 | [options](#options)             | `(SelectOption \| SelectOptionGroup)[]` | `-`             | Options to select                                                                                                             |
 | view                            | `string`                                | `'normal'`      | Control [view](https://github.com/gravity-ui/uikit/blob/main/src/components/TextInput/types.ts#L4)                            |
 | size                            | `string`                                | `'m'`           | Control/options [size](https://github.com/gravity-ui/uikit/blob/main/src/components/TextInput/types.ts#L6)                    |
diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx
index 82c06e05..587161ec 100644
--- a/src/components/Select/Select.tsx
+++ b/src/components/Select/Select.tsx
@@ -45,9 +45,11 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
         renderControl,
         renderFilter,
         renderOption,
+        renderOptionGroup,
         renderSelectedOption,
         renderEmptyOptions,
         getOptionHeight,
+        getOptionGroupHeight,
         filterOption,
         name,
         className,
@@ -274,7 +276,9 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
                         virtualized={virtualized}
                         onOptionClick={handleOptionClick}
                         renderOption={renderOption}
+                        renderOptionGroup={renderOptionGroup}
                         getOptionHeight={getOptionHeight}
+                        getOptionGroupHeight={getOptionGroupHeight}
                         loading={props.loading}
                     />
                 ) : (
diff --git a/src/components/Select/__stories__/SelectShowcase.tsx b/src/components/Select/__stories__/SelectShowcase.tsx
index bb58934c..ac26ce94 100644
--- a/src/components/Select/__stories__/SelectShowcase.tsx
+++ b/src/components/Select/__stories__/SelectShowcase.tsx
@@ -204,13 +204,23 @@ export const SelectShowcase = (props: SelectProps) => {
                             </div>
                         );
                     },
+                    renderOptionGroup: (optionGroup) => {
+                        return (
+                            <div style={{height: 32, lineHeight: '32px'}}>{optionGroup.label}</div>
+                        );
+                    },
                     getOptionHeight: () => 22,
+                    getOptionGroupHeight: () => 32,
                 }}
             >
                 <Select.Option value="val1" content="Value1" data={{color: 'green'}} />
                 <Select.Option value="val2" content="Value2" data={{color: 'red'}} />
                 <Select.Option value="val3" content="Value3" data={{color: 'pink'}} />
                 <Select.Option value="val4" content="Value4" data={{color: 'purple'}} />
+                <Select.OptionGroup label="Group">
+                    <Select.Option value="val5" content="Value3" data={{color: 'orange'}} />
+                    <Select.Option value="val6" content="Value4" data={{color: 'yellow'}} />
+                </Select.OptionGroup>
             </ExampleItem>
             <ExampleItem
                 title="Select with user selected options"
diff --git a/src/components/Select/__stories__/constants.ts b/src/components/Select/__stories__/constants.ts
index 2421ae4c..91e5300f 100644
--- a/src/components/Select/__stories__/constants.ts
+++ b/src/components/Select/__stories__/constants.ts
@@ -120,13 +120,23 @@ export const EXAMPLE_USER_OPTIONS = `const [value, setValue] = React.useState<st
             </div>
         );
     },
+    renderOptionGroup: (optionGroup) => {
+        return (
+            <div style={{height: 32, lineHeight: '32px'}}>{optionGroup.label}</div>
+        );
+    },
     getOptionHeight: () => 22,
+    getOptionGroupHeight: () => 32,
     onUpdate={(nextValue) => setValue1(nextValue)}
 >
     <Select.Option value="val1" content="Value1" data={{color: 'green'}} />
     <Select.Option value="val2" content="Value2" data={{color: 'red'}} />
     <Select.Option value="val3" content="Value3" data={{color: 'pink'}} />
     <Select.Option value="val4" content="Value4" data={{color: 'purple'}} />
+    <Select.OptionGroup label="Group">
+        <Select.Option value="val5" content="Value3" data={{color: 'orange'}} />
+        <Select.Option value="val6" content="Value4" data={{color: 'yellow'}} />
+    </Select.OptionGroup>
 </Select>
 `;
 
diff --git a/src/components/Select/components/SelectList/GroupLabel.tsx b/src/components/Select/components/SelectList/GroupLabel.tsx
index 917816c4..acb0ed6c 100644
--- a/src/components/Select/components/SelectList/GroupLabel.tsx
+++ b/src/components/Select/components/SelectList/GroupLabel.tsx
@@ -1,17 +1,23 @@
 import React from 'react';
 
 import {blockNew as block} from '../../../utils/cn';
+import type {GroupTitleItem} from '../../utils';
 
 const b = block('select-list');
 
 type GroupLabelProps = {
-    label: string;
+    option: GroupTitleItem;
+    renderOptionGroup?: (option: GroupTitleItem) => React.ReactElement;
 };
 
-export const GroupLabel = ({label}: GroupLabelProps) => {
-    return (
-        <div className={b('group-label', {empty: label === ''})}>
-            <div className={b('group-label-content')}>{label}</div>
-        </div>
-    );
+export const GroupLabel = ({option, renderOptionGroup}: GroupLabelProps) => {
+    if (renderOptionGroup) {
+        return <div className={b('group-label-custom')}>{renderOptionGroup(option)}</div>;
+    } else {
+        return (
+            <div className={b('group-label', {empty: option.label === ''})}>
+                <div className={b('group-label-content')}>{option.label}</div>
+            </div>
+        );
+    }
 };
diff --git a/src/components/Select/components/SelectList/SelectList.scss b/src/components/Select/components/SelectList/SelectList.scss
index addf5bfe..5a436ef5 100644
--- a/src/components/Select/components/SelectList/SelectList.scss
+++ b/src/components/Select/components/SelectList/SelectList.scss
@@ -24,13 +24,16 @@ $xl-right-padding: '12px';
         max-height: calc(90vh - 20px);
     }
 
-    &__group-label {
+    &__group-label,
+    &__group-label-custom {
         box-sizing: border-box;
         position: relative;
         width: 100%;
         height: auto;
         padding: 0;
+    }
 
+    &__group-label {
         #{$block}_size_s &:not(#{&}_empty) {
             height: #{variables.$s-height};
             padding: 8px #{select-css-variables.$s-hor-padding} 4px;
diff --git a/src/components/Select/components/SelectList/SelectList.tsx b/src/components/Select/components/SelectList/SelectList.tsx
index eff3bca6..54d719ea 100644
--- a/src/components/Select/components/SelectList/SelectList.tsx
+++ b/src/components/Select/components/SelectList/SelectList.tsx
@@ -4,7 +4,7 @@ import {List} from '../../../List';
 import {SelectQa, selectListBlock} from '../../constants';
 import type {SelectOption, SelectProps} from '../../types';
 import type {FlattenOption} from '../../utils';
-import {getOptionsHeight, getPopupItemHeight} from '../../utils';
+import {GroupTitleItem, getOptionsHeight, getPopupItemHeight} from '../../utils';
 
 import {GroupLabel} from './GroupLabel';
 import {OptionWrap} from './OptionWrap';
@@ -16,7 +16,9 @@ type SelectListProps = {
     mobile: boolean;
     onOptionClick: (option: FlattenOption) => void;
     renderOption?: SelectProps['renderOption'];
+    renderOptionGroup?: SelectProps['renderOptionGroup'];
     getOptionHeight?: SelectProps['getOptionHeight'];
+    getOptionGroupHeight?: SelectProps['getOptionGroupHeight'];
     size: NonNullable<SelectProps['size']>;
     value: NonNullable<SelectProps['value']>;
     flattenOptions: FlattenOption[];
@@ -31,7 +33,9 @@ export const SelectList = React.forwardRef<List<FlattenOption>, SelectListProps>
     const {
         onOptionClick,
         renderOption,
+        renderOptionGroup,
         getOptionHeight,
+        getOptionGroupHeight,
         size,
         flattenOptions,
         value,
@@ -48,29 +52,47 @@ export const SelectList = React.forwardRef<List<FlattenOption>, SelectListProps>
     const optionsHeight = getOptionsHeight({
         options: items,
         getOptionHeight,
+        getOptionGroupHeight,
         size,
         mobile,
     });
 
     const getItemHeight = React.useCallback(
         (option: FlattenOption, index: number) => {
-            return getPopupItemHeight({getOptionHeight, size, option, index, mobile});
+            return getPopupItemHeight({
+                getOptionHeight,
+                getOptionGroupHeight,
+                size,
+                option,
+                index,
+                mobile,
+            });
         },
-        [getOptionHeight, mobile, size],
+        [getOptionHeight, getOptionGroupHeight, mobile, size],
     );
 
     const renderItem = React.useCallback(
         (option: FlattenOption, _isItemActive: boolean, itemIndex: number) => {
             if ('label' in option) {
-                return <GroupLabel label={option.label} />;
+                const wrappedRenderOptionGroup = renderOptionGroup
+                    ? (optionLocal: GroupTitleItem) => {
+                          return renderOptionGroup(optionLocal, {
+                              itemHeight: getItemHeight(optionLocal, itemIndex),
+                          });
+                      }
+                    : undefined;
+
+                return <GroupLabel option={option} renderOptionGroup={wrappedRenderOptionGroup} />;
             }
             if (option.value === loadingOption.value) {
                 return <SelectLoadingIndicator />;
             }
 
             const wrappedRenderOption = renderOption
-                ? (option: SelectOption) => {
-                      return renderOption(option, {itemHeight: getItemHeight(option, itemIndex)});
+                ? (optionLocal: SelectOption) => {
+                      return renderOption(optionLocal, {
+                          itemHeight: getItemHeight(optionLocal, itemIndex),
+                      });
                   }
                 : undefined;
 
@@ -83,7 +105,7 @@ export const SelectList = React.forwardRef<List<FlattenOption>, SelectListProps>
                 />
             );
         },
-        [renderOption, value, multiple, getItemHeight],
+        [renderOption, renderOptionGroup, value, multiple, getItemHeight],
     );
 
     return (
diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts
index 9cdbc5a4..9a522371 100644
--- a/src/components/Select/types.ts
+++ b/src/components/Select/types.ts
@@ -34,6 +34,11 @@ export type SelectRenderOption<T> = (
     options: SelectRenderOptionViewParams,
 ) => React.ReactElement;
 
+export type SelectRenderOptionGroup<T> = (
+    option: Pick<SelectOptionGroup<T>, 'label'>,
+    options: SelectRenderOptionViewParams,
+) => React.ReactElement;
+
 export type SelectSize = InputControlSize;
 
 export type SelectProps<T = any> = QAProps &
@@ -50,9 +55,11 @@ export type SelectProps<T = any> = QAProps &
             style: React.CSSProperties;
         }) => React.ReactElement;
         renderOption?: SelectRenderOption<T>;
+        renderOptionGroup?: SelectRenderOptionGroup<T>;
         renderSelectedOption?: (option: SelectOption<T>, index: number) => React.ReactElement;
         renderEmptyOptions?: ({filter}: {filter: string}) => React.ReactElement;
-        getOptionHeight?: (option: SelectOption<T>) => number;
+        getOptionHeight?: (option: SelectOption<T>, index: number) => number;
+        getOptionGroupHeight?: (option: SelectOptionGroup<T>, index: number) => number;
         filterOption?: (option: SelectOption<T>, filter: string) => boolean;
         view?: InputControlView;
         size?: SelectSize;
diff --git a/src/components/Select/utils.tsx b/src/components/Select/utils.tsx
index 7748d121..0b62992d 100644
--- a/src/components/Select/utils.tsx
+++ b/src/components/Select/utils.tsx
@@ -8,7 +8,7 @@ import type {Option, OptionGroup} from './tech-components';
 import type {SelectOption, SelectOptionGroup, SelectProps, SelectSize} from './types';
 
 // "disable" property needs to deactivate group title item in List
-type GroupTitleItem = {label: string; disabled: true};
+export type GroupTitleItem = {label: string; disabled: true};
 
 export type FlattenOption = SelectOption | GroupTitleItem;
 
@@ -29,12 +29,13 @@ export const getFlattenOptions = (
 
 export const getPopupItemHeight = (args: {
     getOptionHeight?: SelectProps['getOptionHeight'];
+    getOptionGroupHeight?: SelectProps['getOptionGroupHeight'];
     size: SelectSize;
     option: FlattenOption;
     index: number;
     mobile: boolean;
 }) => {
-    const {getOptionHeight, size, option, index, mobile} = args;
+    const {getOptionHeight, getOptionGroupHeight, size, option, index, mobile} = args;
 
     let itemHeight = mobile ? MOBILE_ITEM_HEIGHT : SIZE_TO_ITEM_HEIGHT[size];
 
@@ -42,21 +43,25 @@ export const getPopupItemHeight = (args: {
         const marginTop = index === 0 ? 0 : GROUP_ITEM_MARGIN_TOP;
         itemHeight = option.label === '' ? 0 : itemHeight;
 
-        return itemHeight + marginTop;
+        return getOptionGroupHeight ? getOptionGroupHeight(option, index) : itemHeight + marginTop;
     }
 
-    return getOptionHeight ? getOptionHeight(option) : itemHeight;
+    return getOptionHeight ? getOptionHeight(option, index) : itemHeight;
 };
 
 export const getOptionsHeight = (args: {
     getOptionHeight?: SelectProps['getOptionHeight'];
+    getOptionGroupHeight?: SelectProps['getOptionGroupHeight'];
     size: NonNullable<SelectProps['size']>;
     options: FlattenOption[];
     mobile: boolean;
 }) => {
-    const {getOptionHeight, size, options, mobile} = args;
+    const {getOptionHeight, getOptionGroupHeight, size, options, mobile} = args;
     return options.reduce((height, option, index) => {
-        return height + getPopupItemHeight({getOptionHeight, size, option, index, mobile});
+        return (
+            height +
+            getPopupItemHeight({getOptionHeight, getOptionGroupHeight, size, option, index, mobile})
+        );
     }, 0);
 };
 


Test patch:
diff --git a/src/components/Select/__tests__/Select.popup.test.tsx b/src/components/Select/__tests__/Select.popup.test.tsx
index e1c6b894..7b3d5d55 100644
--- a/src/components/Select/__tests__/Select.popup.test.tsx
+++ b/src/components/Select/__tests__/Select.popup.test.tsx
@@ -1,9 +1,9 @@
 import userEvent from '@testing-library/user-event';
 
-import {SelectQa} from '../constants';
+import {GROUP_ITEM_MARGIN_TOP, SelectQa} from '../constants';
 import type {SelectSize} from '../types';
 
-import {DEFAULT_OPTIONS, TEST_QA, setup} from './utils';
+import {DEFAULT_OPTIONS, GROUPED_OPTIONS, TEST_QA, setup} from './utils';
 
 const onUpdate = jest.fn();
 describe('Select popup', () => {
@@ -63,4 +63,82 @@ describe('Select popup', () => {
             );
         },
     );
+
+    test.each([
+        ['s', {mobile: false, size: 's', height: 28}],
+        ['m', {mobile: false, size: 'm', height: 28}],
+        ['l', {mobile: false, size: 'l', height: 32}],
+        ['xl', {mobile: false, size: 'xl', height: 36}],
+        ['mobile', {mobile: true, size: undefined, height: 32}],
+    ])(
+        'should return correct height for option group depends on size (%s)',
+        async (_type, {size, height, mobile}) => {
+            expect.assertions(2);
+
+            const renderOptionGroup = jest.fn();
+
+            const {getByTestId} = setup(
+                {size: size as SelectSize, renderOptionGroup, onUpdate, options: GROUPED_OPTIONS},
+                mobile,
+            );
+
+            const user = userEvent.setup();
+            const selectControl = getByTestId(TEST_QA);
+            // open select popup
+            await user.click(selectControl);
+
+            expect(renderOptionGroup).toHaveBeenCalledWith(
+                expect.anything(),
+                expect.objectContaining({itemHeight: height}),
+            );
+            expect(renderOptionGroup).toHaveBeenCalledWith(
+                expect.anything(),
+                expect.objectContaining({itemHeight: height + GROUP_ITEM_MARGIN_TOP}),
+            );
+        },
+    );
+
+    test('should use getOptionHeight for render with correct size', async () => {
+        const getOptionHeight = () => 32;
+        const renderOption = jest.fn();
+
+        const {getByTestId} = setup({
+            renderOption,
+            getOptionHeight,
+            onUpdate,
+            options: DEFAULT_OPTIONS,
+        });
+
+        const user = userEvent.setup();
+        const selectControl = getByTestId(TEST_QA);
+        // open select popup
+        await user.click(selectControl);
+
+        expect(renderOption).toHaveBeenCalledWith(
+            expect.anything(),
+            expect.objectContaining({itemHeight: getOptionHeight()}),
+        );
+    });
+
+    test('should use getOptionGroupHeight for render with correct size', async () => {
+        const getOptionGroupHeight = () => 32;
+        const renderOptionGroup = jest.fn();
+
+        const {getByTestId} = setup({
+            renderOptionGroup,
+            getOptionGroupHeight,
+            onUpdate,
+            options: GROUPED_OPTIONS,
+        });
+
+        const user = userEvent.setup();
+        const selectControl = getByTestId(TEST_QA);
+        // open select popup
+        await user.click(selectControl);
+
+        expect(renderOptionGroup).toHaveBeenCalledWith(
+            expect.anything(),
+            expect.objectContaining({itemHeight: getOptionGroupHeight()}),
+        );
+    });
 });