'use client'; import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { getRequiredValueByKey } from '../../helpers/getValueByKey'; import { useAdaptivity } from '../../hooks/useAdaptivity'; import { useExternRef } from '../../hooks/useExternRef'; import { useMergeProps } from '../../hooks/useMergeProps'; import { callMultiple } from '../../lib/callMultiple'; import { useDOM } from '../../lib/dom'; import type { Placement } from '../../lib/floating'; import { defaultFilterFn, type FilterFn } from '../../lib/select'; import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect'; import { preventDefault } from '../../lib/utils'; import { type HasDataAttribute, type HasRootRef } from '../../types'; import { CustomSelectDropdown, type CustomSelectDropdownProps, } from '../CustomSelectDropdown/CustomSelectDropdown'; import { CustomSelectOption } from '../CustomSelectOption/CustomSelectOption'; import type { FormFieldProps } from '../FormField/FormField'; import { type NativeSelectProps, NOT_SELECTED, remapFromNativeValueToSelectValue, type SelectValue, } from '../NativeSelect/NativeSelect'; import { RootComponent } from '../RootComponent/RootComponent'; import type { SelectType } from '../Select/Select'; import { Footnote } from '../Typography/Footnote/Footnote'; import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden'; import { type CustomSelectClearButtonProps } from './CustomSelectClearButton'; import { CustomSelectInput, type CustomSelectInputProps, } from './CustomSelectInput/CustomSelectInput'; import { checkDeprecatedProps, checkMixControlledAndUncontrolledState, checkOptionsValueType, filter, findSelectedIndex, getOptionByValue, } from './helpers'; import { useAfterItems } from './hooks/useAfterItems'; import { useDropdownOpenedController } from './hooks/useDropdownOpenedController'; import { useFocusedOptionController } from './hooks/useFocusedOptionController'; import { useInputKeyboardController } from './hooks/useInputKeyboardController'; import { useInputValueController } from './hooks/useInputValueController'; import { useScrollListController } from './hooks/useScrollListController'; import { useSelectedOptionController } from './hooks/useSelectedOptionController'; import type { CustomSelectOptionInterface, CustomSelectRenderOption, MousePosition, PopupDirection, } from './types'; import styles from './CustomSelect.module.css'; const sizeYClassNames = { none: styles.sizeYNone, compact: styles.sizeYCompact, }; function defaultRenderOptionFn({ option, ...props }: CustomSelectRenderOption): React.ReactNode { return ; } function isMousePositionChanged(event: React.MouseEvent, prevPosition: MousePosition) { return ( Math.abs(prevPosition.x - event.clientX) >= 1 || Math.abs(prevPosition.y - event.clientY) >= 1 ); } const FETCH_STATUS_RESET_DELAY = 2000; const FetchingStatus = ({ fetching = false, options, fetchingInProgressLabel = 'Список опций загружается...', fetchingCompletedLabel = `Загружено опций: ${options.length}`, }: Pick< SelectProps, 'fetching' | 'fetchingInProgressLabel' | 'fetchingCompletedLabel' | 'options' >) => { const [status, setStatus] = React.useState<'fetching' | 'loaded' | 'none'>('none'); const content = getRequiredValueByKey(status, { fetching: fetchingInProgressLabel, loaded: typeof fetchingCompletedLabel === 'function' ? fetchingCompletedLabel(options.length) : fetchingCompletedLabel, none: '', }); useIsomorphicLayoutEffect( function updateStatus() { if (fetching) { setStatus('fetching'); } else { if (status === 'fetching') { setStatus('loaded'); setTimeout(() => setStatus('none'), FETCH_STATUS_RESET_DELAY); } } }, [fetching], ); return {content}; }; export type { CustomSelectClearButtonProps }; export interface SelectProps< OptionInterfaceT extends CustomSelectOptionInterface = CustomSelectOptionInterface, > extends Omit, Omit, Pick, Pick { /** * Свойства, которые можно прокинуть внутрь компонента: * - `root`: свойства для прокидывания в корень компонента; * - `select`: свойства для прокидывания в нативный `select`; * - `input`: свойства для прокидывания в нативный `input`. */ slotProps?: NativeSelectProps['slotProps'] & { input?: React.InputHTMLAttributes & HasDataAttribute & HasRootRef; }; /** * @deprecated Since 7.9.0. Вместо этого используйте `slotProps={ input: { getRootRef: ... } }`. * * Ref на внутрений компонент input. */ getSelectInputRef?: React.Ref; /** * Если `true`, то при нажатии на `CustomSelect` в нём появится текстовое поле для поиска по `options`. По умолчанию поиск * производится по `option.label`. */ searchable?: boolean; /** * Текст, который будет отображен, если приходит пустой `options`. */ emptyText?: string; /** * Событие изменения текстового поля. */ onInputChange?: (e: React.ChangeEvent) => void; /** * Список опций в списке. */ options: OptionInterfaceT[]; /** * Функция для кастомной фильтрации. По умолчанию поиск производится по `option.label`. */ filterFn?: false | FilterFn; /** * Направление раскрытия выпадающего списка. */ popupDirection?: PopupDirection; /** * Рендер-проп для кастомного рендера опции. * В объекте аргумента приходят [свойства опции](https://vkui.io/components/custom-select#custom-select-option-api). * * > ⚠️ Важно: свойство опции `disabled` должно выставляться только через проп `options`. * > Запрещается выставлять `disabled` проп опциям в обход `options`, иначе `CustomSelect` не будет знать об актуальном состоянии * опции. */ renderOption?: (props: CustomSelectRenderOption) => React.ReactNode; /** * Рендер-проп для кастомного рендера содержимого дропдауна. * В `defaultDropdownContent` содержится список опций в виде скроллящегося блока. */ renderDropdown?: ({ defaultDropdownContent, }: { defaultDropdownContent: React.ReactNode; }) => React.ReactNode; /** * Если `true`, то в дропдауне вместо списка опций рисуется спиннер. При переданных `renderDropdown` и `fetching: true` * "победит" `renderDropdown`. */ fetching?: boolean; /** * Обработчик закрытия выпадающего списка. */ onClose?: VoidFunction; /** * Обработчик открытия выпадающего списка. */ onOpen?: VoidFunction; /** * Иконка раскрывающегося списка. */ icon?: React.ReactNode; /** * Кастомная кнопка для очистки значения. * Должна принимать обязательное свойство `onClick`. */ ClearButton?: React.ComponentType; /** * Если `true`, то справа будет отображаться кнопка для очистки значения. */ allowClearButton?: boolean; /** * Передает атрибут `data-testid` для кнопки очистки. */ clearButtonTestId?: string; /** * Отступ от выпадающего списка. */ dropdownOffsetDistance?: number; /** * Ширина раскрывающегося списка зависит от контента. */ dropdownAutoWidth?: boolean; /** * Использовать Portal для рендеринга выпадающего списка. */ forceDropdownPortal?: boolean; /** * Тип отображения компонента. */ selectType?: SelectType; /** * Отключает максимальную высоту по умолчанию. */ noMaxHeight?: boolean; /** * Передает атрибут `data-testid` для элемента, внутри которого отображается текст выбранной опции `CustomSelect` или плейсхолдер. */ labelTextTestId?: string; /** * @deprecated Since 7.9.0. Вместо этого используйте `slotProps={ select: { 'data-testid': ... } }`. * * Передает атрибут `data-testid` для нативного элемента `select`. */ nativeSelectTestId?: string; /** * Обработчик события `keyDown` в поле ввода. */ onInputKeyDown?: (e: React.KeyboardEvent, isOpen: boolean) => void; /** * Включает режим в котором выбранное значение `CustomSelect` читается скринридерами корректно. * В данном режиме введенное в поле ввода значение не сбрасывается при потере фокуса. */ accessible?: boolean /* TODO [>=v8] включить по умолчанию */; /** * Текстовая метка для индикации процесса загрузки данных для пользователей скринридерами. По умолчанию: `"Список опций загружается..."`. */ fetchingInProgressLabel?: string; /** * Текстовая метка для индикации завершения процесса загрузки данных для пользователей скринридерами. По умолчанию: `"Загружено опций: ${options.length}"`. */ fetchingCompletedLabel?: string | ((optionsCount: number) => string); } /** * @see https://vkui.io/components/custom-select */ export function CustomSelect( props: SelectProps, ): React.ReactNode { const { style, className, getRootRef, before, name, getRef, popupDirection = 'bottom', onChange, children, 'onInputChange': onInputChangeProp, renderDropdown, onOpen, onClose, fetching, labelTextTestId, multiline, placeholder, status, forceDropdownPortal, align, selectType = 'default', searchable = false, 'renderOption': renderOptionProp = defaultRenderOptionFn, 'options': options, emptyText = 'Ничего не найдено', filterFn = defaultFilterFn, 'icon': iconProp, ClearButton, allowClearButton = false, dropdownOffsetDistance = 0, dropdownAutoWidth = false, noMaxHeight = false, 'aria-labelledby': ariaLabelledBy, clearButtonTestId, nativeSelectTestId, defaultValue, required, getSelectInputRef, overscrollBehavior, 'onInputKeyDown': onInputKeyDownProp, accessible = false, fetchingInProgressLabel, fetchingCompletedLabel, 'value': selectValue, 'onBlur': onSelectBlur, 'onFocus': onSelectFocus, 'onClick': onSelectClick, slotProps, ...restProps } = props; if (process.env.NODE_ENV === 'development') { checkOptionsValueType(options); checkDeprecatedProps(props); } const { sizeY = 'none' } = useAdaptivity(); const { onClick: onRootClick, onMouseMove: onRootMouseMove, onMouseDown: onRootMouseDown, getRootRef: rootRef, ...rootRest } = useMergeProps( { style, className, getRootRef, }, slotProps?.root, ); const { getRootRef: getSelectRef, ...selectRest } = useMergeProps( { getRootRef: getRef, onBlur: onSelectBlur, onFocus: onSelectFocus, onClick: onSelectClick, }, slotProps?.select, ); const { getRootRef: getInputRef, onChange: onChangeInput, onFocus: onInputFocus, onBlur: onInputBlur, onKeyDown: onNativeInputKeyDown, onClick: onNativeInputClick, readOnly, ...inputRest } = useMergeProps( { getRootRef: getSelectInputRef, onChange: onInputChangeProp, // Приводим типы так как в CustomSelect типы в rest определены как React.SelectHTMLAttributes // Хотя эти свойства прокидываются в input ...(restProps as React.InputHTMLAttributes), }, slotProps?.input, ); const containerRef = React.useRef(null); const handleRootRef = useExternRef(containerRef, rootRef); const selectElRef = useExternRef(getSelectRef); const selectInputRef = useExternRef(getInputRef); const propsValue = React.useMemo(() => { if (selectValue === undefined) { return undefined; } return getOptionByValue(options, selectValue)?.value ?? null; }, [options, selectValue]); const [isControlledOutside, setIsControlledOutside] = React.useState(selectValue !== undefined); const [popperPlacement, setPopperPlacement] = React.useState(popupDirection); const { nativeSelectValue, setNativeSelectValue, selectedOptionValue, setSelectedOptionValue, onNativeSelectChange, } = useSelectedOptionController({ value: propsValue, defaultValue, isControlledOutside, allowClearButton, onChange, }); const selected = React.useMemo( () => options.find((option) => option.value === selectedOptionValue), [options, selectedOptionValue], ); const { inputValue, onInputChange, resetInputValue, resetInputValueBySelectedOption } = useInputValueController({ options, accessible, selectedValue: selectedOptionValue, onInputChange: onChangeInput, }); const filteredOptions = React.useMemo( () => filter(options, searchable ? inputValue : '', filterFn), [filterFn, inputValue, options, searchable], ); const { scrollToElement, optionsWrapperRef, scrollBoxRef } = useScrollListController(); const { focusedOptionValue, setFocusedOptionValue, resetFocusedOption, focusOptionByIndex, focusOption, selectFocusedValue, } = useFocusedOptionController({ selectedOptionValue, filteredOptions, scrollToElement, }); const scrollToSelectedOption = () => { scrollToElement(findSelectedIndex(filteredOptions, selectedOptionValue), true); }; const { opened, open, close, toggleOpened } = useDropdownOpenedController({ onOpen: callMultiple(selectFocusedValue, onOpen), onOpened: scrollToSelectedOption, onClose, onClosed: accessible ? resetInputValueBySelectedOption : resetInputValue, }); React.useEffect( function updateOptionsValue() { const value = propsValue !== undefined ? propsValue : remapFromNativeValueToSelectValue(nativeSelectValue); setSelectedOptionValue(value); setFocusedOptionValue(value); }, [propsValue, nativeSelectValue, setFocusedOptionValue, setSelectedOptionValue], ); React.useEffect( function syncIsControlledState() { setIsControlledOutside((oldIsControlled) => { const newIsControlled = propsValue !== undefined; checkMixControlledAndUncontrolledState(oldIsControlled, newIsControlled); return newIsControlled; }); }, [propsValue], ); useIsomorphicLayoutEffect(() => { if ( filteredOptions.some(({ value }) => nativeSelectValue === value) || (allowClearButton && nativeSelectValue === NOT_SELECTED.NATIVE) ) { const event = new Event('change', { bubbles: true }); selectElRef.current?.dispatchEvent(event); } }, [nativeSelectValue]); const openedClassNames = React.useMemo( () => (opened && dropdownOffsetDistance === 0 && (popperPlacement.includes('top') ? styles.popUp : styles.popDown)) || undefined, [dropdownOffsetDistance, opened, popperPlacement], ); const selectOption = React.useCallback( (value: Exclude) => { setNativeSelectValue(value ?? NOT_SELECTED.NATIVE); close(); const shouldTriggerOnChangeWhenControlledAndInnerValueIsOutOfSync = isControlledOutside && propsValue !== nativeSelectValue && nativeSelectValue === value; if (shouldTriggerOnChangeWhenControlledAndInnerValueIsOutOfSync) { const event = new Event('change', { bubbles: true }); selectElRef.current?.dispatchEvent(event); } }, [close, setNativeSelectValue, isControlledOutside, propsValue, nativeSelectValue, selectElRef], ); const selectFocused = React.useCallback(() => { if (focusedOptionValue === null) { return; } selectOption(focusedOptionValue); }, [focusedOptionValue, selectOption]); const handleInputKeyDown = useInputKeyboardController({ opened, open, close, resetFocusedOption, selectFocused, focusOption, scrollBoxRef, onInputKeyDown: onInputKeyDownProp, }); const onBlur = React.useCallback(() => { close(); const event = new Event('focusout', { bubbles: true }); selectElRef.current?.dispatchEvent(event); }, [close, selectElRef]); const onFocus = React.useCallback(() => { const event = new Event('focusin', { bubbles: true }); selectElRef.current?.dispatchEvent(event); }, [selectElRef]); const handleOptionClick = React.useCallback( (e: React.MouseEvent) => { const index = Array.prototype.indexOf.call( e.currentTarget.parentNode?.children, e.currentTarget, ); const option = filteredOptions[index]; if (option && !option.disabled) { selectOption(option.value); } }, [filteredOptions, selectOption], ); const lastMousePositionRef = React.useRef({ x: 0, y: 0 }); const focusOptionOnMouseMove = React.useCallback( (e: React.MouseEvent, index: number) => { if (isMousePositionChanged(e, lastMousePositionRef.current)) { focusOptionByIndex(index, false); } }, [focusOptionByIndex], ); const popupAriaId = React.useId(); const renderOption = React.useCallback( (option: OptionInterfaceT, index: number) => { const hovered = option.value === focusedOptionValue; const selected = option.value === selectedOptionValue; return ( {renderOptionProp({ option, hovered, children: option.label, selected, disabled: option.disabled, onClick: handleOptionClick, onMouseDown: preventDefault, // Используем `onMouseMove` вместо `onMouseEnter/onMouseOver`. // Потому что если при навигации с клавиатуры курсор наведён на // список, то при первом автоматическом скролле списка вызывается событие MouseOver/MouseEnter // обработчик которого фокусирует опцию под курсором, хотя при навигация с клавиатуры пользователь мог уйти дальше по списку, это путает. // Причём координаты события меняются на пару пикселей по сравнению с прошлым вызовом, // а значит нельзя на них опираться, чтобы запретить обработку такого события. // C mousemove такой проблемы нет, что позволяет реализовать поведение при наведении с клавиатуры и при наведении мышью идентично ` и передавать на него событие клика. // Так как мы больше не оборачиваем CustomSelect в