diff --git a/packages/vkui/src/components/CustomSelect/CustomSelect.stories.tsx b/packages/vkui/src/components/CustomSelect/CustomSelect.stories.tsx index 4cb0ebbde2..97e2002c90 100644 --- a/packages/vkui/src/components/CustomSelect/CustomSelect.stories.tsx +++ b/packages/vkui/src/components/CustomSelect/CustomSelect.stories.tsx @@ -1,16 +1,8 @@ -import * as React from 'react'; -import { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { fn } from '@storybook/test'; -import { Icon24User } from '@vkontakte/icons'; import { CanvasFullLayout, DisableCartesianParam } from '../../storybook/constants'; -import { cities, getRandomUsers } from '../../testing/mock'; -import { Avatar } from '../Avatar/Avatar'; -import { CustomSelectOption } from '../CustomSelectOption/CustomSelectOption'; -import { Div } from '../Div/Div'; -import { FormItem } from '../FormItem/FormItem'; -import { FormLayoutGroup } from '../FormLayoutGroup/FormLayoutGroup'; -import { Header } from '../Header/Header'; -import { CustomSelect, SelectProps } from './CustomSelect'; +import { cities } from '../../testing/mock'; +import { CustomSelect, type SelectProps } from './CustomSelect'; const story: Meta = { title: 'Forms/CustomSelect', @@ -29,126 +21,3 @@ export const Playground: Story = { options: cities, }, }; - -function getUsers(usersArray: ReturnType) { - return usersArray.map((user) => ({ - label: user.name, - value: `${user.id}`, - avatar: user.photo_100, - description: user.screen_name, - })); -} - -export const QAPlayground: Story = { - render: function Render() { - const selectTypes = [ - { - label: 'default', - value: 'default', - }, - { - label: 'plain', - value: 'plain', - }, - { - label: 'accent', - value: 'accent', - }, - ]; - - const [selectType, setSelectType] = React.useState( - undefined, - ); - const users = [...getUsers(getRandomUsers(10))]; - return ( -
-
Custom Select на десктопе
-
Базовые примеры использования
- - - - - - - - setSelectType(e.target.value as SelectProps['selectType'])} - renderOption={({ option, ...restProps }) => ( - - )} - /> - - - - - ( - } - description={option.description} - /> - )} - /> - - - - - - -
Поиск
- - } - placeholder="Введите имя пользователя" - searchable - id="administrator-select-searchable-id-3" - options={users} - allowClearButton - /> - -
- ); - }, -}; diff --git a/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx b/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx index 7b20a29ec5..d64b5b8362 100644 --- a/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx +++ b/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx @@ -25,10 +25,7 @@ jest.mock('../../lib/floating', () => { }; }); -const checkCustomSelectLabelValue = (label: string) => { - expect(screen.getByTestId('labelTextTestId').textContent).toEqual(label); - expect(screen.getByRole('combobox').value).toEqual(label); -}; +const getCustomSelectValue = () => screen.getByTestId('labelTextTestId').textContent; const CustomSelectControlled = ({ options, @@ -77,49 +74,39 @@ describe('CustomSelect', () => { />, ); - checkCustomSelectLabelValue(''); + expect(getCustomSelectValue()).toEqual(''); fireEvent.click(screen.getByTestId('labelTextTestId')); const unselectedOption = screen.getByRole('option', { selected: false, name: 'Josh' }); fireEvent.mouseEnter(unselectedOption); fireEvent.click(unselectedOption); - checkCustomSelectLabelValue('Josh'); + expect(getCustomSelectValue()).toEqual('Josh'); }); it('works correctly as controlled component', () => { const SelectController = () => { - const [value, setValue] = useState('0'); + const [value, setValue] = useState(0); const options = [ - { value: '0', label: 'Mike' }, - { value: '1', label: 'Josh' }, + { value: 0, label: 'Mike' }, + { value: 1, label: 'Josh' }, ]; return ( - - setValue(e.target.value)} - /> - - + setValue(Number(e.target.value))} + /> ); }; render(); - - checkCustomSelectLabelValue('Mike'); - + expect(getCustomSelectValue()).toEqual('Mike'); fireEvent.click(screen.getByTestId('labelTextTestId')); const unselectedOption = screen.getByRole('option', { selected: false, name: 'Josh' }); fireEvent.mouseEnter(unselectedOption); fireEvent.click(unselectedOption); - - checkCustomSelectLabelValue('Josh'); - - fireEvent.click(screen.getByRole('button', { name: /Clear controlled value/ })); - - checkCustomSelectLabelValue(''); + expect(getCustomSelectValue()).toEqual('Josh'); }); it('works correctly with pinned value', () => { @@ -130,14 +117,12 @@ describe('CustomSelect', () => { render(); - checkCustomSelectLabelValue('Mike'); - + expect(getCustomSelectValue()).toEqual('Mike'); fireEvent.click(screen.getByTestId('labelTextTestId')); const unselectedOption = screen.getByRole('option', { selected: false, name: 'Josh' }); fireEvent.mouseEnter(unselectedOption); fireEvent.click(unselectedOption); - - checkCustomSelectLabelValue('Mike'); + expect(getCustomSelectValue()).toEqual('Mike'); }); it('correctly reacts on options change', () => { @@ -152,7 +137,7 @@ describe('CustomSelect', () => { />, ); - checkCustomSelectLabelValue('Josh'); + expect(getCustomSelectValue()).toEqual('Josh'); rerender( { />, ); - checkCustomSelectLabelValue('Josh'); + expect(getCustomSelectValue()).toEqual('Josh'); rerender( { />, ); - checkCustomSelectLabelValue('Felix'); + expect(getCustomSelectValue()).toEqual('Felix'); }); it('correctly converts from controlled to uncontrolled state', () => { @@ -193,7 +178,7 @@ describe('CustomSelect', () => { />, ); - checkCustomSelectLabelValue('Josh'); + expect(getCustomSelectValue()).toEqual('Josh'); rerender( { />, ); - checkCustomSelectLabelValue('Josh'); + expect(getCustomSelectValue()).toEqual('Josh'); fireEvent.click(screen.getByTestId('labelTextTestId')); const unselectedOption = screen.getByRole('option', { selected: false, name: 'Mike' }); fireEvent.mouseEnter(unselectedOption); fireEvent.click(unselectedOption); - checkCustomSelectLabelValue('Mike'); + expect(getCustomSelectValue()).toEqual('Mike'); }); - it('accepts defaultValue', () => { + it('accept defaultValue', () => { render( { />, ); - checkCustomSelectLabelValue('Josh'); + expect(getCustomSelectValue()).toEqual('Josh'); }); it('is searchable', async () => { @@ -243,8 +228,6 @@ describe('CustomSelect', () => { />, ); - checkCustomSelectLabelValue(''); - fireEvent.click(screen.getByTestId('labelTextTestId')); await waitFor(() => expect(screen.getByTestId('inputTestId')).toHaveFocus()); @@ -258,8 +241,7 @@ describe('CustomSelect', () => { key: 'Enter', code: 'Enter', }); - - checkCustomSelectLabelValue('Mike'); + expect(getCustomSelectValue()).toEqual('Mike'); }); it('is custom searchable', () => { @@ -280,8 +262,6 @@ describe('CustomSelect', () => { />, ); - checkCustomSelectLabelValue(''); - fireEvent.click(screen.getByTestId('inputTestId')); fireEvent.change(screen.getByTestId('inputTestId'), { target: { value: 'usa' }, @@ -294,8 +274,7 @@ describe('CustomSelect', () => { key: 'Enter', code: 'Enter', }); - - checkCustomSelectLabelValue('New York'); + expect(getCustomSelectValue()).toEqual('New York'); }); it('is searchable and keeps search results up to date during props.options updates', async () => { @@ -336,7 +315,6 @@ describe('CustomSelect', () => { const { rerender } = render( { />, ); - checkCustomSelectLabelValue('Josh'); - fireEvent.click(screen.getByTestId('inputTestId')); await waitForFloatingPosition(); @@ -361,7 +337,6 @@ describe('CustomSelect', () => { rerender( { rerender( { ); expect(screen.getByRole('option', { selected: true })).toHaveTextContent('Joe'); - checkCustomSelectLabelValue('Joe'); }); // см. https://github.com/VKCOM/VKUI/issues/3600 @@ -410,8 +383,6 @@ describe('CustomSelect', () => { />, ); - checkCustomSelectLabelValue('Категория 3'); - fireEvent.click(screen.getByTestId('inputTestId')); expect(screen.getByRole('option', { selected: true })).toHaveTextContent('Категория 3'); @@ -424,7 +395,7 @@ describe('CustomSelect', () => { fireEvent.mouseEnter(unselectedOption); fireEvent.click(unselectedOption); - checkCustomSelectLabelValue('Категория 2'); + expect(getCustomSelectValue()).toEqual('Категория 2'); }); it('fires onOpen and onClose correctly', async () => { @@ -510,7 +481,7 @@ describe('CustomSelect', () => { await waitForFloatingPosition(); - checkCustomSelectLabelValue('Bob'); + expect(getCustomSelectValue()).toEqual('Bob'); fireEvent.keyDown(screen.getByTestId('inputTestId'), { key: 'Enter', @@ -526,7 +497,7 @@ describe('CustomSelect', () => { code: 'Enter', }); - checkCustomSelectLabelValue('Josh'); + expect(getCustomSelectValue()).toEqual('Josh'); rerender( { code: 'Enter', }); - checkCustomSelectLabelValue('Bob'); + expect(getCustomSelectValue()).toEqual('Bob'); }); // https://github.com/VKCOM/VKUI/issues/4066 @@ -591,8 +562,6 @@ describe('CustomSelect', () => { />, ); - checkCustomSelectLabelValue('Josh'); - rerender( { ); expect(onChange).toHaveBeenCalledTimes(0); - checkCustomSelectLabelValue(''); + expect(getCustomSelectValue()).toEqual(''); }); it('clear value with default clear button', async () => { @@ -629,11 +598,10 @@ describe('CustomSelect', () => { ); expect(onChange).toHaveBeenCalledTimes(0); - checkCustomSelectLabelValue('Mike'); - + expect(getCustomSelectValue()).toEqual('Mike'); expect(screen.getByTestId('inputTestId')).not.toHaveFocus(); fireEvent.click(screen.getByRole('button', { hidden: true })); - checkCustomSelectLabelValue(''); + expect(getCustomSelectValue()).toEqual(''); // focus goes to select input await waitFor(() => expect(screen.getByTestId('inputTestId')).toHaveFocus()); @@ -656,10 +624,10 @@ describe('CustomSelect', () => { />, ); - checkCustomSelectLabelValue('Mike'); + expect(getCustomSelectValue()).toEqual('Mike'); fireEvent.click(screen.getByTestId('clearButtonTestId')); + expect(getCustomSelectValue()).toEqual(''); - checkCustomSelectLabelValue(''); expect(onChange).toHaveBeenCalledTimes(2); }); @@ -703,7 +671,7 @@ describe('CustomSelect', () => { ); expect(onChange).toHaveBeenCalledTimes(0); - checkCustomSelectLabelValue('Mike'); + expect(getCustomSelectValue()).toEqual('Mike'); fireEvent.click(screen.getByTestId('labelTextTestId')); expect(screen.getByRole('option', { selected: true })).toHaveTextContent('Mike'); @@ -742,7 +710,7 @@ describe('CustomSelect', () => { ); expect(onChange).toHaveBeenCalledTimes(0); - checkCustomSelectLabelValue('Mike'); + expect(getCustomSelectValue()).toEqual('Mike'); // clear input fireEvent.click(screen.getByRole('button', { hidden: true })); @@ -812,7 +780,7 @@ describe('CustomSelect', () => { ); expect(onChange).toHaveBeenCalledTimes(0); - checkCustomSelectLabelValue('Mike'); + expect(getCustomSelectValue()).toEqual('Mike'); // первый клик по не выбранной опции без изменения value fireEvent.click(screen.getByTestId('labelTextTestId')); @@ -867,15 +835,15 @@ describe('CustomSelect', () => { labelTextTestId="labelTextTestId" initialValue="0" options={[ - { value: '0', label: 'Mike' }, - { value: '1', label: 'Josh' }, + { value: 0, label: 'Mike' }, + { value: 1, label: 'Josh' }, ]} onChangeStub={onChangeStub} />, ); expect(onChangeStub).toHaveBeenCalledTimes(0); - checkCustomSelectLabelValue('Mike'); + expect(getCustomSelectValue()).toEqual('Mike'); // первый клик по не выбранной опции с изменением value fireEvent.click(screen.getByTestId('labelTextTestId')); @@ -944,7 +912,7 @@ describe('CustomSelect', () => { ); expect(onChange).toHaveBeenCalledTimes(0); - checkCustomSelectLabelValue(''); + expect(getCustomSelectValue()).toEqual(''); // первый клик по не выбранной опции без изменения value fireEvent.click(screen.getByTestId('inputTestId')); @@ -1090,22 +1058,34 @@ describe('CustomSelect', () => { ); }); - it('has placeholder', () => { - render( + it('shows input placeholder for screen readers only if option is not selected', () => { + // Это позволяет скринридеру зачитывать placeholder, если опция не выбрана. + const { rerender } = render( , ); - // input placeholder expect(screen.queryByPlaceholderText('Не выбрано')).toBeTruthy(); - // элемент поверх скрытого инпута - expect(screen.getByTestId('labelTextTestId').textContent).toEqual('Не выбрано'); + + rerender( + , + ); + + expect(screen.queryByPlaceholderText('Не выбрано')).toBeFalsy(); }); it('native select is reachable via nativeSelectTestId', () => { diff --git a/packages/vkui/src/components/CustomSelect/CustomSelect.tsx b/packages/vkui/src/components/CustomSelect/CustomSelect.tsx index b7cd75709c..200148b6f0 100644 --- a/packages/vkui/src/components/CustomSelect/CustomSelect.tsx +++ b/packages/vkui/src/components/CustomSelect/CustomSelect.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { classNames, debounce } from '@vkontakte/vkjs'; import { useAdaptivity } from '../../hooks/useAdaptivity'; import { useExternRef } from '../../hooks/useExternRef'; +import { useFocusWithin } from '../../hooks/useFocusWithin'; import { useDOM } from '../../lib/dom'; import type { Placement } from '../../lib/floating'; import { defaultFilterFn, type FilterFn } from '../../lib/select'; @@ -12,24 +13,21 @@ import { CustomSelectDropdown, CustomSelectDropdownProps, } from '../CustomSelectDropdown/CustomSelectDropdown'; +import { + CustomSelectOption, + type CustomSelectOptionProps, +} from '../CustomSelectOption/CustomSelectOption'; import { DropdownIcon } from '../DropdownIcon/DropdownIcon'; import { FormFieldProps } from '../FormField/FormField'; import { NativeSelectProps } from '../NativeSelect/NativeSelect'; import { SelectType } from '../Select/Select'; import { Footnote } from '../Typography/Footnote/Footnote'; +import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden'; import { CustomSelectClearButton, type CustomSelectClearButtonProps, } from './CustomSelectClearButton'; import { CustomSelectInput } from './CustomSelectInput'; -import { - calculateInputValueFromOptions, - defaultRenderOptionFn, - findIndexAfter, - findIndexBefore, - findSelectedIndex, -} from './helpers'; -import type { CustomSelectOptionInterface, CustomSelectRenderOption } from './types'; import styles from './CustomSelect.module.css'; const sizeYClassNames = { @@ -37,6 +35,32 @@ const sizeYClassNames = { ['compact']: styles['CustomSelect--sizeY-compact'], }; +const findIndexAfter = (options: CustomSelectOptionInterface[] = [], startIndex = -1) => { + if (startIndex >= options.length - 1) { + return -1; + } + return options.findIndex((option, i) => i > startIndex && !option.disabled); +}; + +const findIndexBefore = ( + options: CustomSelectOptionInterface[] = [], + endIndex: number = options.length, +) => { + let result = -1; + if (endIndex <= 0) { + return result; + } + for (let i = endIndex - 1; i >= 0; i--) { + let option = options[i]; + + if (!option.disabled) { + result = i; + break; + } + } + return result; +}; + const warn = warnOnce('CustomSelect'); const checkOptionsValueType = (options: T[]) => { @@ -48,10 +72,33 @@ const checkOptionsValueType = (options: T } }; +function defaultRenderOptionFn({ + option, + ...props +}: CustomSelectRenderOption): React.ReactNode { + return ; +} + const handleOptionDown: MouseEventHandler = (e: React.MouseEvent) => { e.preventDefault(); }; +function findSelectedIndex( + options: T[] = [], + value: SelectValue, + withClear: boolean, +) { + if (withClear && value === '') { + return -1; + } + return ( + options.findIndex((item) => { + value = typeof item.value === 'number' ? Number(value) : value; + return item.value === value; + }) ?? -1 + ); +} + const filter = ( options: SelectProps['options'], inputValue: string, @@ -62,7 +109,21 @@ const filter = ( : options; }; -export type { CustomSelectClearButtonProps, CustomSelectOptionInterface, CustomSelectRenderOption }; +type SelectValue = React.SelectHTMLAttributes['value']; + +export interface CustomSelectOptionInterface { + value: SelectValue; + label: React.ReactElement | string; + disabled?: boolean; + [index: string]: any; +} + +export interface CustomSelectRenderOption + extends CustomSelectOptionProps { + option: T; +} + +export type { CustomSelectClearButtonProps }; export interface SelectProps< OptionInterfaceT extends CustomSelectOptionInterface = CustomSelectOptionInterface, @@ -219,18 +280,14 @@ export function CustomSelect(null); const selectElRef = useExternRef(getRef); const optionsWrapperRef = React.useRef(null); - const selectInputRef = useExternRef(getSelectInputRef); const [focusedOptionIndex, setFocusedOptionIndex] = React.useState(-1); const [isControlledOutside, setIsControlledOutside] = React.useState(props.value !== undefined); + const [inputValue, setInputValue] = React.useState(''); const [nativeSelectValue, setNativeSelectValue] = React.useState( () => props.value ?? defaultValue ?? (allowClearButton ? '' : undefined), ); - const [inputValue, setInputValue] = React.useState(() => - calculateInputValueFromOptions(optionsProp, nativeSelectValue), - ); - const [popperPlacement, setPopperPlacement] = React.useState(popupDirection); const [options, setOptions] = React.useState(optionsProp); const [selectedOptionIndex, setSelectedOptionIndex] = React.useState( @@ -369,6 +426,7 @@ export function CustomSelect { resetKeyboardInput(); + setInputValue(''); setOpened(false); resetFocusedOption(); onClose?.(); @@ -378,8 +436,8 @@ export function CustomSelect { const item = options[index]; - close(); setNativeSelectValue(item?.value); + close(); const shouldTriggerOnChangeWhenControlledAndInnerValueIsOutOfSync = isControlledOutside && @@ -415,15 +473,12 @@ export function CustomSelect { const event = new Event('focusin', { bubbles: true }); selectElRef.current?.dispatchEvent(event); - selectInputRef.current?.select(); - }, [selectElRef, selectInputRef]); + }, [selectElRef]); const onClick = React.useCallback(() => { if (opened) { @@ -453,40 +508,27 @@ export function CustomSelect = (e) => { @@ -670,6 +712,7 @@ export function CustomSelect>(); const focusOnInput = React.useCallback(() => { clearTimeout(focusOnInputTimerRef.current); @@ -771,9 +814,7 @@ export function CustomSelect = { 'role': 'combobox', 'aria-controls': popupAriaId, + 'aria-owns': popupAriaId, 'aria-expanded': opened, ['aria-activedescendant']: ariaActiveDescendantId && opened ? `${popupAriaId}-${ariaActiveDescendantId}` : undefined, @@ -796,6 +838,8 @@ export function CustomSelect + {focusWithin && selected && !opened && ( + {selected.label} + )} + > + {selected?.label} +