'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, 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 { 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 densityClassNames = { none: styles.densityNone, compact: styles.densityCompact, }; 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) | undefined; }) | undefined; /** * @deprecated Since 7.9.0. Вместо этого используйте `slotProps={ input: { getRootRef: ... } }`. * * Ref на внутрений компонент input. */ getSelectInputRef?: React.Ref | undefined; /** * Если `true`, то при нажатии на `CustomSelect` в нём появится текстовое поле для поиска по `options`. По умолчанию поиск * производится по `option.label`. */ searchable?: boolean | undefined; /** * Текст, который будет отображен, если приходит пустой `options`. */ emptyText?: string | undefined; /** * Событие изменения текстового поля. */ onInputChange?: ((e: React.ChangeEvent) => void) | undefined; /** * Список опций в списке. */ options: OptionInterfaceT[]; /** * Функция для кастомной фильтрации. По умолчанию поиск производится по `option.label`. */ filterFn?: false | FilterFn | undefined; /** * Направление раскрытия выпадающего списка. */ popupDirection?: PopupDirection | undefined; /** * Рендер-проп для кастомного рендера опции. * В объекте аргумента приходят [свойства опции](https://vkui.io/components/custom-select#custom-select-option-api). * * > ⚠️ Важно: свойство опции `disabled` должно выставляться только через проп `options`. * > Запрещается выставлять `disabled` проп опциям в обход `options`, иначе `CustomSelect` не будет знать об актуальном состоянии * опции. */ renderOption?: (props: CustomSelectRenderOption) => React.ReactNode | undefined; /** * Рендер-проп для кастомного рендера содержимого дропдауна. * В `defaultDropdownContent` содержится список опций в виде скроллящегося блока. */ renderDropdown?: ({ defaultDropdownContent, }: { defaultDropdownContent: React.ReactNode; }) => React.ReactNode; /** * Если `true`, то в дропдауне вместо списка опций рисуется спиннер. При переданных `renderDropdown` и `fetching: true` * "победит" `renderDropdown`. */ fetching?: boolean | undefined; /** * Обработчик закрытия выпадающего списка. */ onClose?: VoidFunction | undefined; /** * Обработчик открытия выпадающего списка. */ onOpen?: VoidFunction | undefined; /** * Кастомная кнопка для очистки значения. * Должна принимать обязательное свойство `onClick`. */ ClearButton?: React.ComponentType | undefined; /** * Если `true`, то справа будет отображаться кнопка для очистки значения. */ allowClearButton?: boolean | undefined; /** * Передает атрибут `data-testid` для кнопки очистки. */ clearButtonTestId?: string | undefined; /** * Отступ от выпадающего списка. */ dropdownOffsetDistance?: number | undefined; /** * Ширина раскрывающегося списка зависит от контента. */ dropdownAutoWidth?: boolean | undefined; /** * Использовать Portal для рендеринга выпадающего списка. */ forceDropdownPortal?: boolean | undefined; /** * Отключает максимальную высоту по умолчанию. */ noMaxHeight?: boolean | undefined; /** * Передает атрибут `data-testid` для элемента, внутри которого отображается текст выбранной опции `CustomSelect` или плейсхолдер. */ labelTextTestId?: string | undefined; /** * @deprecated Since 7.9.0. Вместо этого используйте `slotProps={ select: { 'data-testid': ... } }`. * * Передает атрибут `data-testid` для нативного элемента `select`. */ nativeSelectTestId?: string | undefined; /** * Обработчик события `keyDown` в поле ввода. */ onInputKeyDown?: ((e: React.KeyboardEvent, isOpen: boolean) => void) | undefined; /** * @deprecated Since 8.0.0. Будет удалено в 9.0.0. * * Включает режим в котором выбранное значение `CustomSelect` читается скринридерами корректно. * В данном режиме введенное в поле ввода значение не сбрасывается при потере фокуса. */ accessible?: boolean | undefined /* TODO [>=v9] удалить свойство */; /** * Текстовая метка для индикации процесса загрузки данных для пользователей скринридерами. По умолчанию: `"Список опций загружается..."`. */ fetchingInProgressLabel?: string | undefined; /** * Текстовая метка для индикации завершения процесса загрузки данных для пользователей скринридерами. По умолчанию: `"Загружено опций: ${options.length}"`. */ fetchingCompletedLabel?: string | ((optionsCount: number) => string) | undefined; /** * @deprecated Будет удалено в 10.0.0, используйте `selectType`. * * Режим отображения. * * - `default` — показывает фон, обводку и, при наличии, текст-подсказку. * - `plain` — показывает только текст-подсказку. */ mode?: 'default' | 'plain' | undefined; } /** * @see https://vkui.io/components/custom-select */ export function CustomSelect( props: SelectProps, ): React.ReactNode { const { // FormFieldProps status, before, // CustomSelectDropdownProps overscrollBehavior, // SelectProps children, getSelectInputRef, searchable = false, emptyText = 'Ничего не найдено', 'onInputChange': onInputChangeProp, 'options': options, filterFn = defaultFilterFn, popupDirection = 'bottom', 'renderOption': renderOptionProp = defaultRenderOptionFn, renderDropdown, fetching, onClose, onOpen, ClearButton, allowClearButton = false, clearButtonTestId, dropdownOffsetDistance = 0, dropdownAutoWidth = false, forceDropdownPortal, noMaxHeight = false, labelTextTestId, nativeSelectTestId, 'onInputKeyDown': onInputKeyDownProp, accessible = true, fetchingInProgressLabel, fetchingCompletedLabel, // NativeSelectProps 'value': selectValue, defaultValue, onChange, getRef, multiline, placeholder, 'icon': iconProp, selectType, mode, align, form, // Input props minLength, maxLength, pattern, autoFocus, disabled, id, 'readOnly': readOnlyProp, // Select props required, name, 'onClick': onSelectClick, 'onFocus': onSelectFocus, 'onBlur': onSelectBlur, // other 'aria-labelledby': ariaLabelledBy, slotProps, ...restProps } = props; if (process.env.NODE_ENV === 'development') { checkOptionsValueType(options); checkDeprecatedProps(props); } const { density = 'none' } = useAdaptivity(); const { onClick: onRootClick, onMouseMove: onRootMouseMove, onMouseDown: onRootMouseDown, getRootRef: rootRef, ...rootRest } = useMergeProps< Omit, 'children'> & HasDataAttribute & HasRootRef >(restProps, slotProps?.root); const { getRootRef: getSelectRef, ...selectRest } = useMergeProps( { getRootRef: getRef, required, name, form, onClick: onSelectClick, onFocus: onSelectFocus, onBlur: onSelectBlur, }, slotProps?.select, ); const { getRootRef: getInputRef, onChange: onChangeInput, onFocus: onInputFocus, onBlur: onInputBlur, onKeyDown: onNativeInputKeyDown, onClick: onNativeInputClick, readOnly, ...inputRest } = useMergeProps( { getRootRef: getSelectInputRef, onChange: onInputChangeProp, minLength, maxLength, pattern, autoFocus, disabled, id, readOnly: readOnlyProp, placeholder, }, 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({ /** * Компилятор сходит с ума из-за рефа внутри focusOptionOnMouseMove. * Обходной путь прокидывать ref в свойства для рендер пропов. */ ...(false ? { __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: lastMousePositionRef, } : {}), option, hovered, children: option.label, selected, disabled: option.disabled, onClick: handleOptionClick, onMouseDown: preventDefault, // Используем `onMouseMove` вместо `onMouseEnter/onMouseOver`. // Потому что если при навигации с клавиатуры курсор наведён на // список, то при первом автоматическом скролле списка вызывается событие MouseOver/MouseEnter // обработчик которого фокусирует опцию под курсором, хотя при навигация с клавиатуры пользователь мог уйти дальше по списку, это путает. // Причём координаты события меняются на пару пикселей по сравнению с прошлым вызовом, // а значит нельзя на них опираться, чтобы запретить обработку такого события. // C mousemove такой проблемы нет, что позволяет реализовать поведение при наведении с клавиатуры и при наведении мышью идентично ` и передавать на него событие клика. // Так как мы больше не оборачиваем CustomSelect в