Repository: gravity-ui/uikit
Pull request #368 (2022-10-23 17:17:08)

test(Select): add tests for selection handling

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

Patch:
diff --git a/src/components/Select/components/SelectControl/SelectControl.tsx b/src/components/Select/components/SelectControl/SelectControl.tsx
index 8eb7e4a4..f4c5e308 100644
--- a/src/components/Select/components/SelectControl/SelectControl.tsx
+++ b/src/components/Select/components/SelectControl/SelectControl.tsx
@@ -1,14 +1,13 @@
 import React from 'react';
 import {useForkRef} from '../../../utils/useForkRef';
-import {CnMods, block} from '../../../utils/cn';
+import type {CnMods} from '../../../utils/cn';
 import {Icon} from '../../../Icon';
 import {Chevron} from '../../../icons/Chevron';
-import {SelectProps} from '../../types';
+import type {SelectProps} from '../../types';
+import {selectBlock} from '../../constants';
 
 import './SelectControl.scss';
 
-const b = block('select');
-
 type ControlProps = {
     setActive: (nextActive: boolean) => void;
     onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;
@@ -77,7 +76,7 @@ export const SelectControl = React.forwardRef<HTMLElement, ControlProps>((props,
         <button
             ref={handleControlRef as React.Ref<HTMLButtonElement>}
             name={name}
-            className={b(mods, className)}
+            className={selectBlock(mods, className)}
             style={inlineStyles}
             aria-haspopup="listbox"
             disabled={disabled}
@@ -86,10 +85,12 @@ export const SelectControl = React.forwardRef<HTMLElement, ControlProps>((props,
             type="button"
             data-qa={qa}
         >
-            {label && <span className={b('label')}>{label}</span>}
-            {showPlaceholder && <span className={b('placeholder')}>{placeholder}</span>}
-            {showOptionsText && <span className={b('option-text')}>{optionsText.join(', ')}</span>}
-            <Icon className={b('chevron-icon')} data={Chevron} />
+            {label && <span className={selectBlock('label')}>{label}</span>}
+            {showPlaceholder && <span className={selectBlock('placeholder')}>{placeholder}</span>}
+            {showOptionsText && (
+                <span className={selectBlock('option-text')}>{optionsText.join(', ')}</span>
+            )}
+            <Icon className={selectBlock('chevron-icon')} data={Chevron} />
         </button>
     );
 });
diff --git a/src/components/Select/constants.ts b/src/components/Select/constants.ts
index f0fd092f..917d62b2 100644
--- a/src/components/Select/constants.ts
+++ b/src/components/Select/constants.ts
@@ -3,6 +3,8 @@ import {SelectProps} from './types';
 
 export const LIST_CLASSNAME = block('select-popup')('list');
 
+export const selectBlock = block('select');
+
 export const SIZE_TO_ITEM_HEIGHT: Record<NonNullable<SelectProps['size']>, number> = {
     s: 28,
     m: 28,


Test patch:
diff --git a/src/components/Select/__tests__/Select.base-actions.test.tsx b/src/components/Select/__tests__/Select.base-actions.test.tsx
index 02c933b0..7205b0c2 100644
--- a/src/components/Select/__tests__/Select.base-actions.test.tsx
+++ b/src/components/Select/__tests__/Select.base-actions.test.tsx
@@ -1,35 +1,19 @@
 import React from 'react';
 import userEvent from '@testing-library/user-event';
-import {render} from '@testing-library/react';
 import {ListQa} from '../../List';
-import {Select, SelectOption, SelectOptionGroup, SelectQa} from '..';
+import {SelectQa} from '..';
 import {QUICK_SEARCH_TIMEOUT} from '../constants';
-import {generateOptions, timeout} from './utils';
-
-const TEST_QA = 'select-test-qa';
-const DEFAULT_OPTIONS = generateOptions([
-    ['js', 'JavaScript'],
-    ['python', 'Python'],
-    ['ruby', 'Ruby'],
-]);
-const QUICK_SEARCH_OPTIONS = generateOptions(40);
-const GROUPED_QUICK_SEARCH_OPTIONS: SelectOptionGroup[] = [
-    {label: 'Group 1', options: generateOptions(40).slice(0, 20)},
-    {label: 'Group 2', options: generateOptions(40).slice(20)},
-];
-const GROUPED_OPTIONS: SelectOptionGroup[] = [
-    {label: 'Group 1', options: DEFAULT_OPTIONS.slice(0, 2)},
-    {label: 'Group 2', options: DEFAULT_OPTIONS.slice(2)},
-];
+import {
+    TEST_QA,
+    DEFAULT_OPTIONS,
+    QUICK_SEARCH_OPTIONS,
+    GROUPED_OPTIONS,
+    GROUPED_QUICK_SEARCH_OPTIONS,
+    setup,
+    timeout,
+} from './utils';
 
 describe('Select base actions', () => {
-    const setup = ({
-        options = DEFAULT_OPTIONS,
-    }: {options?: (SelectOption | SelectOptionGroup)[]} = {}) => {
-        const utils = render(<Select qa={TEST_QA} options={options} />);
-        return utils;
-    };
-
     describe('open popup by', () => {
         test('click', async () => {
             const {getByTestId} = setup();
diff --git a/src/components/Select/__tests__/Select.muitiple.test.tsx b/src/components/Select/__tests__/Select.muitiple.test.tsx
new file mode 100644
index 00000000..7a705c90
--- /dev/null
+++ b/src/components/Select/__tests__/Select.muitiple.test.tsx
@@ -0,0 +1,154 @@
+import {cleanup} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+    TEST_QA,
+    SELECT_CONTROL_ACTIVE_CLASS,
+    DEFAULT_OPTIONS,
+    GROUPED_OPTIONS,
+    OptionsListType,
+    setup,
+} from './utils';
+
+afterEach(cleanup);
+
+const onUpdate = jest.fn();
+const onOpenChange = jest.fn();
+const SELECTED_OPTIONS = [DEFAULT_OPTIONS[0], DEFAULT_OPTIONS[1]];
+
+describe('Select multiple mode actions', () => {
+    describe('select options by click in', () => {
+        test.each([
+            [OptionsListType.FLAT, SELECTED_OPTIONS],
+            [OptionsListType.GROUPED, SELECTED_OPTIONS],
+        ])('%s list', async (type, selectedOptions) => {
+            const options = type === 'grouped' ? GROUPED_OPTIONS : DEFAULT_OPTIONS;
+            const {getByTestId, getByText} = setup({
+                options,
+                multiple: true,
+                onUpdate,
+                onOpenChange,
+            });
+            const user = userEvent.setup();
+            const selectControl = getByTestId(TEST_QA);
+            // open select popup
+            await user.click(selectControl);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            let option = getByText(selectedOptions[0].content as string);
+            // select first option
+            await user.click(option);
+            expect(onUpdate).toHaveBeenLastCalledWith([selectedOptions[0].value]);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            option = getByText(selectedOptions[1].content as string);
+            // select second option
+            await user.click(option);
+            expect(onUpdate).toHaveBeenLastCalledWith([
+                selectedOptions[0].value,
+                selectedOptions[1].value,
+            ]);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            // close select popup
+            await user.keyboard('[Escape]');
+            expect(selectControl).not.toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+        });
+    });
+
+    describe('deselect options by click in', () => {
+        test.each([
+            [OptionsListType.FLAT, SELECTED_OPTIONS],
+            [OptionsListType.GROUPED, SELECTED_OPTIONS],
+        ])('%s list', async (type, selectedOptions) => {
+            const options = type === 'grouped' ? GROUPED_OPTIONS : DEFAULT_OPTIONS;
+            const {getByTestId, getByText} = setup({
+                options,
+                value: [selectedOptions[0].value, selectedOptions[1].value],
+                multiple: true,
+                onUpdate,
+                onOpenChange,
+            });
+            const user = userEvent.setup();
+            const selectControl = getByTestId(TEST_QA);
+            // open select popup
+            await user.click(selectControl);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            const option = getByText(selectedOptions[0].content as string);
+            // deselect first option
+            await user.click(option);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            expect(onUpdate).toHaveBeenLastCalledWith([selectedOptions[1].value]);
+            // close select popup
+            await user.keyboard('[Escape]');
+            expect(selectControl).not.toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+        });
+    });
+
+    describe('select option by', () => {
+        test.each([
+            ['Enter', OptionsListType.FLAT, SELECTED_OPTIONS],
+            ['Enter', OptionsListType.GROUPED, SELECTED_OPTIONS],
+            ['Space', OptionsListType.FLAT, SELECTED_OPTIONS],
+            ['Space', OptionsListType.GROUPED, SELECTED_OPTIONS],
+        ])('%s in %s list', async (key, type, selectedOptions) => {
+            const options = type === 'grouped' ? GROUPED_OPTIONS : DEFAULT_OPTIONS;
+            const {getByTestId} = setup({options, multiple: true, onUpdate, onOpenChange});
+            const user = userEvent.setup();
+            const selectControl = getByTestId(TEST_QA);
+            await user.keyboard('[Tab]');
+            // open select popup
+            await user.keyboard(`[${key}]`);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            // select active option (first option by default)
+            await user.keyboard(`[${key}]`);
+            expect(onUpdate).toHaveBeenLastCalledWith([selectedOptions[0].value]);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            // focus next option
+            await user.keyboard('[ArrowDown]');
+            // select next option
+            await user.keyboard(`[${key}]`);
+            expect(onUpdate).toHaveBeenCalledWith([
+                selectedOptions[0].value,
+                selectedOptions[1].value,
+            ]);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            // close select popup
+            await user.keyboard('[Escape]');
+            expect(selectControl).not.toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+        });
+    });
+
+    describe('deselect option by', () => {
+        test.each([
+            ['Enter', OptionsListType.FLAT, SELECTED_OPTIONS],
+            ['Enter', OptionsListType.GROUPED, SELECTED_OPTIONS],
+            ['Space', OptionsListType.FLAT, SELECTED_OPTIONS],
+            ['Space', OptionsListType.GROUPED, SELECTED_OPTIONS],
+        ])('%s in %s list', async (key, type, selectedOptions) => {
+            const options = type === 'grouped' ? GROUPED_OPTIONS : DEFAULT_OPTIONS;
+            const {getByTestId} = setup({
+                options,
+                value: [selectedOptions[0].value, selectedOptions[1].value],
+                multiple: true,
+                onUpdate,
+                onOpenChange,
+            });
+            const user = userEvent.setup();
+            const selectControl = getByTestId(TEST_QA);
+            await user.keyboard('[Tab]');
+            // open select popup
+            await user.keyboard(`[${key}]`);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            // select active option (first option by default)
+            await user.keyboard(`[${key}]`);
+            expect(onUpdate).toHaveBeenLastCalledWith([selectedOptions[1].value]);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            // focus next option
+            await user.keyboard('[ArrowDown]');
+            // select next option
+            await user.keyboard(`[${key}]`);
+            expect(onUpdate).toHaveBeenCalledWith([]);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            // close select popup
+            await user.keyboard('[Escape]');
+            expect(selectControl).not.toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+        });
+    });
+});
diff --git a/src/components/Select/__tests__/Select.single.test.tsx b/src/components/Select/__tests__/Select.single.test.tsx
new file mode 100644
index 00000000..ed1dbb13
--- /dev/null
+++ b/src/components/Select/__tests__/Select.single.test.tsx
@@ -0,0 +1,60 @@
+import {cleanup} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+    TEST_QA,
+    SELECT_CONTROL_ACTIVE_CLASS,
+    DEFAULT_OPTIONS,
+    GROUPED_OPTIONS,
+    OptionsListType,
+    setup,
+} from './utils';
+
+afterEach(cleanup);
+
+const onUpdate = jest.fn();
+const onOpenChange = jest.fn();
+const SELECTED_OPTION = DEFAULT_OPTIONS[0];
+
+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) => {
+            const options = type === 'grouped' ? GROUPED_OPTIONS : DEFAULT_OPTIONS;
+            const {getByTestId, getByText} = setup({options, onUpdate, onOpenChange});
+            const user = userEvent.setup();
+            const selectControl = getByTestId(TEST_QA);
+            // open select popup
+            await user.click(selectControl);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            const option = getByText(selectedOption.content as string);
+            // select option
+            await user.click(option);
+            expect(onUpdate).toHaveBeenCalledWith([selectedOption.value]);
+            expect(selectControl).not.toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+        });
+    });
+
+    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) => {
+            const options = type === 'grouped' ? GROUPED_OPTIONS : DEFAULT_OPTIONS;
+            const {getByTestId} = setup({options, onUpdate, onOpenChange});
+            const user = userEvent.setup();
+            const selectControl = getByTestId(TEST_QA);
+            await user.keyboard('[Tab]');
+            // open select popup
+            await user.keyboard(`[${key}]`);
+            expect(selectControl).toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+            // select active option (first option by default)
+            await user.keyboard(`[${key}]`);
+            expect(onUpdate).toHaveBeenCalledWith([selectedOption.value]);
+            expect(selectControl).not.toHaveClass(SELECT_CONTROL_ACTIVE_CLASS);
+        });
+    });
+});
diff --git a/src/components/Select/__tests__/utils.ts b/src/components/Select/__tests__/utils.ts
deleted file mode 100644
index eff8e6a4..00000000
--- a/src/components/Select/__tests__/utils.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import {act} from '@testing-library/react';
-import range from 'lodash/range';
-import {SelectOption} from '..';
-
-export const generateOptions = (args: number | [string, string][]): SelectOption[] => {
-    if (typeof args === 'number') {
-        return range(0, args).map((i) => ({
-            value: `val${i + 1}`,
-            content: `Value ${i + 1}`,
-        }));
-    }
-
-    return args.map(([value, content]) => ({value, content}));
-};
-
-export const timeout = (ms: number) => {
-    // https://testing-library.com/docs/react-testing-library/api/#act
-    // https://reactjs.org/docs/test-utils.html#act
-    return act(async () => {
-        return new Promise((resolve) => {
-            setTimeout(resolve, ms);
-        });
-    });
-};
diff --git a/src/components/Select/__tests__/utils.tsx b/src/components/Select/__tests__/utils.tsx
new file mode 100644
index 00000000..6fbdc097
--- /dev/null
+++ b/src/components/Select/__tests__/utils.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import {act, render} from '@testing-library/react';
+import {range} from 'lodash';
+import {Select, SelectProps, SelectOption, SelectOptionGroup} from '..';
+import {selectBlock} from '../constants';
+
+export const OptionsListType = {
+    FLAT: 'flat',
+    GROUPED: 'grouped',
+} as const;
+export const TEST_QA = 'select-test-qa';
+export const SELECT_CONTROL_ACTIVE_CLASS = selectBlock({active: true});
+export const DEFAULT_OPTIONS = generateOptions([
+    ['js', 'JavaScript'],
+    ['python', 'Python'],
+    ['ruby', 'Ruby'],
+]);
+export const GROUPED_OPTIONS: SelectOptionGroup[] = [
+    {label: 'Group 1', options: DEFAULT_OPTIONS.slice(0, 2)},
+    {label: 'Group 2', options: DEFAULT_OPTIONS.slice(2)},
+];
+export const QUICK_SEARCH_OPTIONS = generateOptions(40);
+export const GROUPED_QUICK_SEARCH_OPTIONS: SelectOptionGroup[] = [
+    {label: 'Group 1', options: generateOptions(40).slice(0, 20)},
+    {label: 'Group 2', options: generateOptions(40).slice(20)},
+];
+
+const ControlledSelect = (props: Partial<SelectProps>) => {
+    const {options = DEFAULT_OPTIONS, value, onUpdate, ...restProps} = props;
+    const [stateValue, setStateValue] = React.useState(value || []);
+
+    const handleUpdate = (nextValue: string[]) => {
+        onUpdate?.(nextValue);
+        setStateValue(nextValue);
+    };
+
+    return (
+        <Select
+            {...restProps}
+            qa={TEST_QA}
+            options={options}
+            value={stateValue}
+            onUpdate={handleUpdate}
+        />
+    );
+};
+
+export function setup(props: Partial<SelectProps> = {}) {
+    const utils = render(<ControlledSelect {...props} />);
+    return utils;
+}
+
+export function timeout(ms: number) {
+    // https://testing-library.com/docs/react-testing-library/api/#act
+    // https://reactjs.org/docs/test-utils.html#act
+    return act(async () => {
+        return new Promise((resolve) => {
+            setTimeout(resolve, ms);
+        });
+    });
+}
+
+export function generateOptions(args: number | [string, string][]): SelectOption[] {
+    if (typeof args === 'number') {
+        return range(0, args).map((i) => ({
+            value: `val${i + 1}`,
+            content: `Value ${i + 1}`,
+        }));
+    }
+
+    return args.map(([value, content]) => ({value, content}));
+}