import { bubble } from "@web3r/flowerkit/evt"; import { wait, getDebouncedFn } from "@web3r/flowerkit/fn"; import { useRef, useEffect, useState, useMemo, useCallback, useImperativeHandle, } from "react"; import type { SyntheticEvent, MouseEvent, FC, ReactElement, FocusEvent, KeyboardEvent, Dispatch, SetStateAction, AnimationEventHandler, TouchEventHandler, } from "react"; import type { ActionMeta, GroupBase, OnChangeValue, OptionsOrGroups, } from "react-select"; import ReactSelect from "react-select"; import AsyncSelect from "react-select/async"; import { getDocument } from "ssr-window"; import type { IInputRef } from "#package/components/input"; import type { IComponentBaseProps } from "#package/types"; import { selectEmptyOptions } from "./config/constants"; import type { TOptionType, IGetOptionsModifyParams, IStateActions, ISelectRef, TOnResetParams, ISelect, TSelectCtx, TSelectValue, TSelectPackageAPI, } from "./config/types"; import { SelectContext } from "./context"; import { getInitialValue, getOptions, isOptionsEqual, onOptionChange, getComponents, getStateClasses, } from "./utils"; import { constants } from "../../constants"; import { getElRefHelper } from "../../utils/react/getElRefHelper"; import { useCN } from "../../utils/react/useCN"; import { useSSGRender } from "../../utils/react/useSSGRender"; import "./style.pcss"; /** * Компонент выпадающего списка * @returns {ReactElement} */ const Select: FC = (props): ReactElement => { const { baseClass = "formSelect", extraClasses = {}, extraAttrs = {}, name = "", id, options = selectEmptyOptions, getOptionsModify, onChange, onKeyDown, onClick, onReset, onMount, onUnmount, onBlur, onMenuToggle, onSelectChangeRequest, onOptionsSearchRequest, form, value, placeholder = "", tabIndex = -1, isLoading = false, isRequired = false, isDisabled = false, isSearchable = false, isClearable = false, isCheckboxView = false, isCountView = false, isAsync = false, isMulti = false, isAutoScroll = false, loadOptionsDebounce = 500, formatOptionLabel = ({ label, data = {} }: TOptionType) => `${data?.prefix ?? ""}${label ?? ""}`, formatCount, customComponents = {}, closeMenuOnSelect, closeMenuOnScroll = false, menuShouldBlockScroll = false, autoFocus = false, openMenuOnFocus = true, backspaceRemovesValue = false, openMenuOnClick = true, blurInputOnSelect, escapeClearsValue = true, menuShouldScrollIntoView = false, captureMenuScroll = false, hideSelectedOptions = false, icons = { loading: "loading", dropdown: "dropdown", clear: "clear", remove: "remove", }, styles = { clearIndicator: () => {}, clearValue: () => {}, container: () => {}, control: () => {}, dropdownIndicator: () => {}, group: () => {}, groupHeading: () => {}, indicatorsContainer: () => {}, indicatorSeparator: () => {}, input: () => {}, loadingIndicator: () => {}, loadingMessage: () => {}, menu: () => {}, menuList: () => {}, menuPortal: () => {}, multiValue: () => {}, multiValueLabel: () => {}, multiValueRemove: () => {}, noOptionsMessage: () => {}, option: () => {}, placeholder: () => {}, singleValue: () => {}, valueContainer: () => {}, }, isOpen, ref, } = props || {}; const { getCN } = useCN(baseClass); const selectRef = useRef(null); const inputRef = useRef(null); const containerRef = useRef(null); const normalizedValue = useMemo>(() => getInitialValue({ value, isMulti, options }), [ value, isMulti, options ]); const originalValue = useRef>(normalizedValue); const originalOptions = useRef(options); const stateClasses = useMemo(() => getStateClasses(getCN), [ getCN ]) as Record; // main state values const [ selectValue, setSelectValue ] = useState>(normalizedValue); const [ selectOptions, setSelectOptions ] = useState(options); const [ selectDisabled, setSelectDisabled ] = useState(isDisabled); const [ selectLoading, setSelectLoading ] = useState(isLoading); const [ selectRequired, setSelectRequired ] = useState(isRequired); const isMenuControlled = typeof isOpen === "boolean"; const [ selectIsOpen, setSelectIsOpen ] = useState(Boolean(isOpen)); const [ isMenuManaged, setIsMenuManaged ] = useState(isMenuControlled); // prev state values const prevControlledOpen = useRef(undefined); const prevValue = useRef>(selectValue); const prevOptions = useRef(selectOptions); const lastSelectedValueRef = useRef(null); const animationRef = useRef(false); const prevStateClassesRef = useRef>(stateClasses); const getSelectValueSnapshot = useCallback((optionsSnapshot: TOptionType[]): TSelectValue => { const refValue = selectRef.current?.getValue() ?? selectEmptyOptions; // eslint-disable-next-line no-nested-ternary const stateValue = Array.isArray(selectValue) ? selectValue : (selectValue ? [ selectValue ] : selectEmptyOptions); const sourceValue = refValue.length ? refValue : stateValue; const valueByOptionsRef = sourceValue.map((item) => { if (!item?.value) { return item; } return optionsSnapshot.find((option) => option?.value === item.value) ?? item; }); if (isMulti) { return valueByOptionsRef as TSelectValue; } return (valueByOptionsRef[0] ?? null) as TSelectValue; }, [ isMulti, selectValue ]); const applyOptionsModify = useCallback((type: IGetOptionsModifyParams["type"]) => { if (typeof getOptionsModify !== "function") { return; } setSelectOptions((prev) => { const optionsSnapshot = [ ...prev ]; const valueSnapshot = getSelectValueSnapshot(optionsSnapshot); const valueForModify = type === "close" && isMulti && Array.isArray(valueSnapshot) && !!lastSelectedValueRef.current ? (() => { const latest = valueSnapshot.find((item) => item?.value === lastSelectedValueRef.current); if (!latest) { return valueSnapshot; } return [ latest, ...valueSnapshot.filter((item) => item !== latest) ] as TSelectValue; })() : valueSnapshot; const nextOptions = getOptionsModify({ options: optionsSnapshot, value: valueForModify, prevOptions: [ ...prev ], type, }); if (!Array.isArray(nextOptions)) { return prev; } const isSameByRef = nextOptions.length === prev.length && nextOptions.every((item, index) => item === prev[index]); return isSameByRef ? prev : nextOptions; }); }, [ getOptionsModify, getSelectValueSnapshot, isMulti ]); const onMenuOpen = useCallback(() => { if (animationRef.current) { return; } const containerEl = getElRefHelper(containerRef); if (containerEl) { wait(50).finally(() => { containerEl.classList.add(stateClasses.isOpen); }); } applyOptionsModify("open"); if (typeof onMenuToggle === "function") { onMenuToggle("open"); } }, [ applyOptionsModify, onMenuToggle ]); const onMenuClose = useMemo(() => getDebouncedFn(() => { const container = getElRefHelper(containerRef); const valueInput = getElRefHelper(inputRef); if (container) { container.classList.remove(stateClasses.isOpen); if (valueInput) { bubble(valueInput, "blur", { container, }); } } applyOptionsModify("close"); if (typeof onMenuToggle === "function") { onMenuToggle("close"); } }, 300), [ applyOptionsModify, onMenuToggle ]); const handleMenuOpen = useCallback(() => { if (!isMenuControlled) { setIsMenuManaged(true); } setSelectIsOpen(true); onMenuOpen(); }, [ isMenuControlled, onMenuOpen ]); const handleMenuClose = useCallback(() => { if (animationRef.current) { return; } if (!isMenuControlled) { setIsMenuManaged(true); } setSelectIsOpen(false); onMenuClose(); }, [ isMenuControlled, onMenuClose ]); const onContainerClick = useCallback( (e: MouseEvent) => { if (typeof onClick === "function") { onClick({ event: e.nativeEvent, stateActions, ref: selectRef, getData: () => ({ value: selectValue, options: selectOptions, original: { value, options, }, }), }); } }, [ selectOptions, onClick, selectValue, value, options ] ); const handleValidate = (e: CustomEvent) => { const containerEl = getElRefHelper(containerRef); if (containerEl) { containerEl.classList.toggle(constants.stateClasses.invalid, !e.detail.isValid); } }; const setSelectIsOpenState = useCallback>>((nextState) => { setSelectIsOpen((prev) => { const resolvedState = typeof nextState === "function" ? (nextState as (prevState: boolean | undefined) => boolean | undefined)(prev) : nextState; return Boolean(resolvedState); }); }, []); const stateActions = useMemo( () => ({ setSelectValue, setSelectOptions, setSelectDisabled, setSelectLoading, setSelectRequired, setSelectIsOpen: setSelectIsOpenState, }), [ setSelectIsOpenState ]); const loadOptions = useMemo(() => getDebouncedFn(( inputValue: string, callback: (options: OptionsOrGroups>) => void ): void => { if (onOptionsSearchRequest) { void getOptions({ value: inputValue, callback, onOptionsSearchRequest, stateActions, name, }); } }, loadOptionsDebounce), [ onOptionsSearchRequest, stateActions, name, loadOptionsDebounce ]); const handleReset = useCallback( (e: SyntheticEvent) => { const originalOpts = originalOptions.current; const originalVal = originalValue.current; if (typeof onReset === "function") { onReset({ event: e, stateActions, ref: selectRef, getData: () => ({ value: selectValue, options: selectOptions, original: { value: originalVal, options: originalOpts, }, }), } as TOnResetParams); } else { setSelectOptions(originalOpts); setSelectValue(originalVal); } }, [ onReset, selectValue, selectOptions ] ); const handleChange = useCallback( async (data: OnChangeValue, actionData: ActionMeta) => { if (isMulti) { if (actionData?.action === "select-option" && actionData.option?.value) { lastSelectedValueRef.current = actionData.option.value; } else if (!Array.isArray(data) || !data.length) { lastSelectedValueRef.current = null; } else { const tail = data[data.length - 1]; lastSelectedValueRef.current = tail?.value ?? null; } } await onOptionChange({ actionData, data, onSelectChangeRequest, stateActions, selectRef, onChange, name, selectOptions, inputRef, }); }, [ selectOptions, onChange, onSelectChangeRequest, name, isMulti ] ); const handleBlur = useCallback( (e: FocusEvent) => { if (typeof onBlur === "function") { onBlur({ event: e.nativeEvent, stateActions, ref: selectRef, getData: () => ({ value: selectValue, options: selectOptions, original: { value, options, }, }), }); } }, [ onBlur, selectOptions, selectValue, value, options ] ); const handleKeyDown = useCallback(async (event: KeyboardEvent) => { if (typeof onKeyDown === "function") { onKeyDown({ event: event.nativeEvent, stateActions, getData: () => ({ value: selectValue, options: selectOptions, isLoading: selectLoading || false, }), ref: selectRef, }); } else { const inputValue = (event.currentTarget as HTMLInputElement | null)?.value ?? ""; if ( event?.code?.toLowerCase() === "backspace" && backspaceRemovesValue && !!selectValue && !isCountView && !inputValue.length ) { event.preventDefault(); if (isMulti) { const nextValues = (selectValue as TOptionType[]).slice(0, -1); const newValue = nextValues.length ? nextValues : null; setSelectValue(newValue); await handleChange(newValue, { action: "remove-value" } as ActionMeta); } else { setSelectValue(null); if (isAsync) { setSelectOptions(selectEmptyOptions); } await handleChange(null, { action: "clear" } as ActionMeta); } } } }, [ onKeyDown, stateActions, selectValue, selectOptions, selectLoading, backspaceRemovesValue, isCountView, isMulti, isAsync, handleChange ]); const resolvedCloseMenuOnSelect = closeMenuOnSelect ?? !isMulti; const resolvedBlurInputOnSelect = blurInputOnSelect ?? !isMulti; const baseProps = useMemo(() => { const passThrough = { options: selectOptions, isOptionDisabled: (option: TOptionType) => !!option?.data?.isDisabled || false, onChange: handleChange, onKeyDown: handleKeyDown, value: selectValue, inputId: id || name, closeMenuOnSelect: resolvedCloseMenuOnSelect, blurInputOnSelect: resolvedBlurInputOnSelect, isDisabled: selectDisabled || selectLoading, menuShouldScrollIntoView, isLoading: selectLoading, isMulti, className: getCN("inner"), classNamePrefix: getCN(), onBlur: handleBlur, name, form, placeholder, tabIndex, isSearchable, isClearable, menuIsOpen: (isMenuControlled || isMenuManaged) ? selectIsOpen : undefined, onMenuOpen: handleMenuOpen, onMenuClose: handleMenuClose, formatOptionLabel, closeMenuOnScroll, menuShouldBlockScroll, autoFocus, openMenuOnFocus, backspaceRemovesValue, openMenuOnClick, escapeClearsValue, captureMenuScroll, hideSelectedOptions, styles, }; return Object.fromEntries( Object.entries(passThrough).filter(([ _, v ]) => v !== undefined) ); }, [ selectValue, handleMenuOpen, handleMenuClose, selectDisabled, selectLoading, selectOptions, handleChange, handleKeyDown, handleBlur, name, selectIsOpen, id, isMulti, form, placeholder, tabIndex, isSearchable, isClearable, formatOptionLabel, resolvedCloseMenuOnSelect, closeMenuOnScroll, menuShouldBlockScroll, autoFocus, openMenuOnFocus, backspaceRemovesValue, openMenuOnClick, resolvedBlurInputOnSelect, escapeClearsValue, menuShouldScrollIntoView, captureMenuScroll, hideSelectedOptions, styles, isMenuControlled, isMenuManaged, ]); const components = useMemo(() => getComponents(customComponents), [ customComponents ]); const className = getCN("", { isOpen: !!selectIsOpen, isValue: Array.isArray(selectValue) ? !!selectValue.length : !!selectValue, isSearchable, isCheckboxView, isAsync, isMulti, isCountView, ...extraClasses, }); const ctx = useMemo( () => ({ getCN, icons, isAsync, isSearchable, isCheckboxView, name, form, isAutoScroll, stateActions, inputRef, handleValidate, handleReset, getOptionsModify, selectValue, isRequired: selectRequired, isDisabled: selectDisabled, isCountView, isMulti, formatCount, formatOptionLabel, }), [ icons, isAsync, isSearchable, isCheckboxView, name, form, isAutoScroll, stateActions, inputRef, handleValidate, handleReset, getOptionsModify, selectValue, selectRequired, selectDisabled, isCountView, isMulti, formatCount, formatOptionLabel ]); const onAnimationStart = useCallback((e) => { const el = getElRefHelper(containerRef); if (!!el && !!e.target && (e.target as HTMLElement).classList.contains(getCN("menu"))) { animationRef.current = true; el.classList.add(stateClasses.isTransition); // fallback wait(1000).finally(() => { animationRef.current = false; el.classList.remove(stateClasses.isTransition); }); } }, [ getCN, stateClasses.isTransition ]); const onAnimationEnd = useCallback((e) => { const el = getElRefHelper(containerRef); if (!!el && !!e.target && (e.target as HTMLElement).classList.contains(getCN("menu"))) { animationRef.current = false; el.classList.remove(stateClasses.isTransition); } }, [ getCN, stateClasses.isTransition ]); const onTouchEnd = useCallback((e) => { e.stopPropagation(); }, []); useImperativeHandle( ref, () => ({ el: getElRefHelper(containerRef) as HTMLDivElement | null, ref: selectRef, utils: null, getData: () => ({ value: selectValue, options: selectOptions, isLoading: !!selectLoading, }), stateActions, }), [ selectValue, selectOptions, selectLoading, stateActions ] ); useEffect(() => { if (typeof onMount === "function") { onMount(); } return () => { if (typeof onUnmount === "function") { onUnmount(); } }; }, []); useEffect(() => { if (prevValue.current !== selectValue) { const valueInput = getElRefHelper(inputRef); if (valueInput) { bubble(valueInput, "change", { type: "select", refs: { selectRef, inputRef }, data: { action: "change", name, value: selectValue, options: selectOptions, }, stateActions, }); } console.debug(`[Select] Value was changed for select with id/name "${id || name}"`, selectValue); prevValue.current = selectValue; } }, [ selectValue, selectOptions, stateActions, id, name ]); useEffect(() => { if (!isOptionsEqual(options, prevOptions.current)) { setSelectOptions(options); if (value !== undefined) { setSelectValue(options.length ? normalizedValue : null); } prevOptions.current = options; } }, [ options, normalizedValue, value ]); useEffect(() => { if (value !== undefined) { setSelectValue((prev) => (prev !== normalizedValue ? normalizedValue : prev)); } }, [ value, normalizedValue ]); useEffect(() => { const containerEl = getElRefHelper(containerRef); if (containerEl) { const prevStateClasses = prevStateClassesRef.current; containerEl.classList.remove( prevStateClasses.isRequired, prevStateClasses.isDisabled, prevStateClasses.isLoading ); containerEl.classList.toggle(stateClasses.isRequired, !!selectRequired); containerEl.classList.toggle(stateClasses.isDisabled, !!selectDisabled); containerEl.classList.toggle(stateClasses.isLoading, !!selectLoading); prevStateClassesRef.current = stateClasses; } }, [ stateClasses, selectRequired, selectDisabled, selectLoading ]); useEffect(() => { if (isRequired !== selectRequired) { setSelectRequired(isRequired); } }, [ isRequired ]); useEffect(() => { if (isDisabled !== selectDisabled) { setSelectDisabled(isDisabled); } }, [ isDisabled ]); useEffect(() => { if (isLoading !== selectLoading) { setSelectLoading(isLoading); } }, [ isLoading ]); useEffect(() => { if (isMenuControlled) { setSelectIsOpen(Boolean(isOpen)); } }, [ isMenuControlled, isOpen ]); useEffect(() => { if (!isMenuControlled) { return; } if (prevControlledOpen.current === undefined) { prevControlledOpen.current = selectIsOpen; if (selectIsOpen) { onMenuOpen(); } return; } if (prevControlledOpen.current !== selectIsOpen) { selectIsOpen ? onMenuOpen() : onMenuClose(); prevControlledOpen.current = selectIsOpen; } }, [ isMenuControlled, selectIsOpen, onMenuOpen, onMenuClose ]); useEffect(() => { if (!selectIsOpen) { return; } const onDocumentClick = (e: MouseEvent) => { const target = e.target; const containerEl = getElRefHelper(containerRef); const isInsideSelect = ( (!!containerEl && !!target && containerEl.contains(target as Node)) || (target as HTMLElement).getAttribute("role") === "option" ); if (!!target && getDocument().documentElement.contains(target as HTMLElement) && !isInsideSelect) { handleMenuClose(); selectRef.current?.blur(); } }; getDocument().addEventListener("click", onDocumentClick); return () => { getDocument().removeEventListener("click", onDocumentClick); }; }, [ selectIsOpen, getCN, isMenuControlled, handleMenuClose ]); useEffect(() => { if (normalizedValue !== selectValue && typeof getOptionsModify === "function") { applyOptionsModify("change"); } }, [ selectValue, normalizedValue, getOptionsModify, applyOptionsModify ]); const SSGComponent = useSSGRender("data-js-select", props); if (SSGComponent) { return SSGComponent; } return (
{isAsync ? ( ref={selectRef} loadOptions={loadOptions} components={components} {...baseProps} /> ) : ( ref={selectRef} components={components} {...baseProps} /> )}
); }; export type { ISelect, ISelectRef, TOptionType, TSelectPackageAPI, TSelectValue, }; export { Select };