import React, { useState, useEffect, useRef, useCallback, useMemo, } from "react"; import clsx from "clsx"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import type { SelectProps, ExtendedSelectData } from "./types"; import selectTheme from "./selectTheme"; import { TEInput } from "tw-elements-react"; import TESelectOptionList from "./SelectOptionList/SelectOptionList"; import { UP_ARROW, DOWN_ARROW, ENTER, TAB, ESCAPE } from "./keycodes"; import { isArraysEqual } from "./utils"; import { useTransition } from "../../hooks/useTransition"; const SELECT_ALL_INDEX = -1; const TESelect: React.FC = ({ open = false, setOpen, data, className, optionHeight = 38, visibleOptions = 5, disabled, placeholder, label, clearBtn, children, multiple, displayedLabels = 5, optionsSelectedLabel = "options selected", selectAll = true, selectAllLabel = "Select all", size, showRef, contrast = false, onOpen, onClose, onValueChange, onOptionSelect, onOptionDeselect, search = false, searchLabel = "Search...", autoSelect = false, noResultsText = "No results", preventFirstSelection = false, value, tag: Tag = "div", theme: customTheme, container, clearIcon, selectIcon, ...props }) => { // dropdown state const [isMounted, setIsMounted] = useState(false); const [showDropdown, setShowDropdown] = useState(open); const [isRendered, setIsRendered] = useState(false); const [isOpen, setIsOpen] = useState(false); // styling const [inputWidth, setInputWidth] = useState("0px"); const [maxDropdownHeight, setMaxDropdownHeight] = useState(0); const [fakeValue, setFakeValue] = useState(null); // inputs value const [inputValue, setInputValue] = useState(""); const [query, setQuery] = useState(""); const [activeElementIndex, setActiveElementIndex] = useState(() => { return selectAll ? SELECT_ALL_INDEX : data.findIndex((el) => !el.disabled); }); // selected data const [selectedElements, setSelectedElements] = useState([]); const [selectData, setSelectData] = useState([]); // references const [referenceElement, setReferenceElement] = useState(); const [popperElement, setPopperElement] = useState(); const searchRef = useRef(null); const dropdownWrapperRef = useRef(null); const dropdownRef = useRef(null); const arrowRef = useRef(null); const filteredData = useMemo( () => selectData.filter( (item) => item.text?.toLocaleLowerCase().includes(query.toLocaleLowerCase()) || item.optgroup ), [query, selectData] ); // classnames const theme = { ...selectTheme, ...customTheme, }; const wrapperClasses = clsx("relative", className); const dropdownClasses = clsx( theme.dropdown, showDropdown ? theme.dropdownVisible : theme.dropdownHidden ); const optionsWrapperClasses = clsx( theme.optionsWrapper, theme.optionsWrapperScrollbar ); const arrowClasses = clsx( theme.selectArrow, contrast && theme.selectArrowWhite, !size && theme.selectArrowDefault, size === "sm" && theme.selectArrowSm, size === "lg" && theme.selectArrowLg, document.activeElement === referenceElement ? contrast ? theme.selectArrowWhiteFocused : theme.selectArrowFocused : null ); const clearBtnClasses = clsx( theme.selectClearBtn, contrast && theme.selectClearBtnWhite, !size && theme.selectClearBtnDefault, size === "sm" && theme.selectClearBtnSm, size === "lg" && theme.selectClearBtnLg ); // popper const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: "bottom-start", }); const handleResize = useCallback(() => { showDropdown && setInputWidth(`${referenceElement?.offsetWidth}px`); }, [referenceElement, showDropdown]); const scrollOptions = (index: number) => { if (selectData.length === 0) { return; } const list = dropdownWrapperRef.current as HTMLElement; const listHeight = list.offsetHeight; const scrollTop = list.scrollTop; const hiddenOptionsCount = selectData.filter( (el) => el.hidden && el.elementPosition < index ).length; const indexCount = multiple && selectAll ? index + 1 : index; const firstIndex = multiple && selectAll ? -2 : -1; if (index > firstIndex) { const optionOffset = (indexCount - hiddenOptionsCount) * optionHeight; const isBelow = optionOffset + optionHeight > scrollTop + listHeight; const isAbove = optionOffset < scrollTop; if (isAbove) { list.scrollTop = optionOffset; } else if (isBelow) { list.scrollTop = optionOffset - listHeight + optionHeight; } else { list.scrollTop = scrollTop; } } }; // transition const { onTransitionShow, onTransitionHide } = useTransition( dropdownRef.current ); const setNextOptionActive = (): number => { const findOptionIndexInFilteredData = (index: number) => { return filteredData.findIndex( (option) => option.elementPosition == index ); }; let index = activeElementIndex; while (index < selectData.length - 1) { index++; if (multiple && index === SELECT_ALL_INDEX) { return SELECT_ALL_INDEX; } const isOptionInFilteredData = findOptionIndexInFilteredData(index) != -1; const isOptionDisabledOrHidden = selectData[index].disabled || selectData[index].hidden; const isPrevOptionIsLastInFilteredData = filteredData[findOptionIndexInFilteredData(index - 1)] === filteredData[filteredData.length - 1]; if ( (index === selectData.length && (!isOptionInFilteredData || isOptionDisabledOrHidden)) || isPrevOptionIsLastInFilteredData ) { return activeElementIndex; } else if (isOptionInFilteredData && !isOptionDisabledOrHidden) { break; } } return index; }; const setPreviousOptionActive = (): number => { let index = activeElementIndex; while (index >= 0) { index -= 1; const isActiveElementInFilteredData = filteredData.findIndex((option) => option.elementPosition == index) != -1; const isOptionDisabledOrHidden = index >= 0 && (selectData[index].disabled || selectData[index].hidden); if ( index <= 0 && (!isActiveElementInFilteredData || isOptionDisabledOrHidden) ) { return multiple && selectAll ? (index = -1) : activeElementIndex; } else if (isActiveElementInFilteredData && !isOptionDisabledOrHidden) { break; } } return index; }; const handleKeyboard = (e: React.KeyboardEvent) => { const { key } = e; if (![UP_ARROW, DOWN_ARROW, ENTER, TAB, ESCAPE].includes(key)) { return; } if (key === TAB) { autoSelect && handleOptionClick(filteredData[activeElementIndex]); referenceElement?.focus(); return setIsOpen(false); } e.preventDefault(); if ((e.altKey && key === DOWN_ARROW) || (e.altKey && key === UP_ARROW)) { return setIsOpen(!isOpen); } if (key === DOWN_ARROW) { const newIndex = setNextOptionActive(); if (!isOpen) { return multiple ? setIsOpen(true) : setSelectedElements([newIndex]); } scrollOptions(newIndex); return setActiveElementIndex(newIndex); } if (key === UP_ARROW) { const newIndex = setPreviousOptionActive(); if (!isOpen) { return multiple ? setIsOpen(true) : setSelectedElements([newIndex]); } scrollOptions(newIndex); return setActiveElementIndex(newIndex); } if (key === ENTER) { const selectedOptionIndex = filteredData.findIndex( (option) => option.elementPosition == activeElementIndex ); if (!isOpen) return setIsOpen(true); return activeElementIndex === SELECT_ALL_INDEX ? handleSelectAll() : handleOptionClick(filteredData[selectedOptionIndex]); } if (key === ESCAPE) { setIsOpen(false); return referenceElement?.focus(); } }; const toggleOpen = useCallback(() => { setIsOpen((prevState) => !prevState); }, []); const handleClickOutside = useCallback( (e: MouseEvent) => { if (showRef && showRef.current === e.target) { return; } const popperExist = popperElement && popperElement !== null; const referenceExist = referenceElement && referenceElement !== null; const isOutsideSelect = !popperElement?.contains(e.target as Node) && !referenceElement?.contains(e.target as Node); const isArrow = (e.target as Node) === arrowRef.current; if (popperExist && isOpen && referenceExist) { if (isOutsideSelect && !isArrow) { setIsOpen(false); } } }, [popperElement, referenceElement, isOpen, showRef] ); useEffect(() => { const preparedData = data.map((el, index) => { return { ...el, elementPosition: index }; }); if (isArraysEqual(preparedData, selectData)) { return; } setSelectData(preparedData); }, [data, selectData]); const handleSelectAll = () => { if (!multiple || !selectAll) { return; } if ( selectedElements.length === data.filter((el) => !el.disabled && !el.optgroup).length ) { setSelectedElements([]); onValueChange?.([]); return updateMultipleInput([]); } const dataIndexes = selectData ?.filter((el) => !el.disabled && !el.optgroup) .map((el) => el.elementPosition); setSelectedElements(dataIndexes); onValueChange?.(data.filter((el) => !el.disabled)); return updateMultipleInput(dataIndexes); }; const updateInput = useCallback(() => { if (selectData.length === 0) return; let selectedOptionIndex: number | undefined = selectedElements[0]; const noSelectedOption: boolean = selectedOptionIndex === undefined; if (isRendered && !multiple) { if ( (noSelectedOption && preventFirstSelection) || !isRendered || multiple ) { return; } else if (noSelectedOption) { selectedOptionIndex = 0; } const optionValue = selectData[selectedOptionIndex].value; const optionText = String(selectData[selectedOptionIndex].text); if (!optionText) { setFakeValue(null); setInputValue(optionValue ? " " : ""); } else { setFakeValue(optionValue ? null : optionText); setInputValue(optionValue ? optionText : ""); } } }, [ multiple, isRendered, selectedElements, selectData, preventFirstSelection, ]); const updateMultipleInput = useCallback( (newData: number[]) => { const isTextExtended = displayedLabels === -1 || newData.length > displayedLabels; if (newData.length <= 0) { setFakeValue(null); setInputValue(""); } if (isTextExtended) { setFakeValue(null); return setInputValue(`${newData.length} ${optionsSelectedLabel}`); } const selectedOptionsTextArray = newData .map((itemIndex: number) => selectData[itemIndex].text || "") .filter((value: any) => value !== ""); const selectedOptionsValuesArray = newData .map((itemIndex: number) => selectData[itemIndex].value || "") .filter((value: any) => value !== ""); const isEveryOptionTextEmpty = selectedOptionsTextArray.findIndex((text: string) => text !== "") === -1; const isAnyOptionWithValue = selectedOptionsValuesArray.length > 0; const optionsTextCombined = selectedOptionsTextArray.join(", "); if (isEveryOptionTextEmpty) { setFakeValue(null); isAnyOptionWithValue ? setInputValue(" ") : setInputValue(""); } else { isAnyOptionWithValue ? (setInputValue(optionsTextCombined), setFakeValue(null)) : (setInputValue(""), setFakeValue(optionsTextCombined)); } }, [displayedLabels, optionsSelectedLabel, selectData] ); useEffect(() => { updateInput(); }, [selectedElements, updateInput]); const handleOptionClick = (el: ExtendedSelectData) => { if (!el || el.disabled) { return; } const { elementPosition } = el; if (multiple) { const isSelected = selectedElements.includes(elementPosition); isSelected ? onOptionDeselect?.(data[elementPosition]) : onOptionSelect?.(data[elementPosition]); const newData = isSelected ? selectedElements.filter( (itemIndex: number) => itemIndex !== elementPosition ) : [...selectedElements, elementPosition]; setSelectedElements(newData); setActiveElementIndex(elementPosition); onValueChange?.(newData.map((itemIndex) => data[itemIndex])); return updateMultipleInput(newData); } onOptionDeselect?.(data[activeElementIndex]); setSelectedElements([elementPosition]); onValueChange?.(data[elementPosition]); onOptionSelect?.(data[elementPosition]); setIsOpen(false); return referenceElement?.focus(); }; useEffect(() => { setIsRendered(true); }, []); useEffect(() => { if (multiple) { const selectedItems = selectData .filter((el) => el.defaultSelected) .map((el) => el.elementPosition); updateMultipleInput(selectedItems); return setSelectedElements(selectedItems); } let selectedIndex = selectData.findIndex((el) => el.defaultSelected); // if there's no default selected item - get first not disabled one if (selectedIndex === SELECT_ALL_INDEX && !preventFirstSelection) { selectedIndex = selectData.findIndex((el) => !el.disabled && !el.hidden); } // additional check if all elements are disabled // and there's no default selected element if (selectedIndex !== SELECT_ALL_INDEX) { setSelectedElements([selectedIndex]); } }, [selectData, updateMultipleInput, multiple, preventFirstSelection]); useEffect(() => { if (isOpen && search && selectedElements.length === 0) { return setActiveElementIndex(SELECT_ALL_INDEX); } if (!isOpen) { selectedElements.length > 0 ? setActiveElementIndex(Math.max(...selectedElements)) : setActiveElementIndex(multiple ? SELECT_ALL_INDEX - 1 : 0); } }, [filteredData, isOpen, search, selectedElements, selectedElements]); useEffect(() => { setMaxDropdownHeight(visibleOptions * optionHeight); }, [visibleOptions, optionHeight]); useEffect(() => { handleResize(); }, [handleResize]); useEffect(() => { if (isOpen) { window.addEventListener("click", handleClickOutside); window.addEventListener("resize", handleResize); } return () => { window.removeEventListener("click", handleClickOutside); window.removeEventListener("resize", handleResize); }; }, [handleClickOutside, handleResize, isOpen]); useEffect(() => { if (showRef) { const selector = showRef.current; selector?.addEventListener("click", toggleOpen); return () => { selector?.removeEventListener("click", toggleOpen); }; } }, [showRef, toggleOpen]); useEffect(() => { if (!value) return; const selectValuesArray = Array.isArray(value) ? value : [value]; const selectedOptionsIndexes = selectData .filter((el) => el.value && selectValuesArray.includes(el.value)) .map((el) => el.elementPosition); if (selectedOptionsIndexes.toString() === selectedElements.toString()) return; const dataToReturn = multiple ? selectedOptionsIndexes.map((itemIndex) => selectData[itemIndex]) : selectData[selectedOptionsIndexes[0]]; onValueChange?.(dataToReturn); setSelectedElements(selectedOptionsIndexes); multiple ? updateMultipleInput(selectedOptionsIndexes) : updateInput(); }, [ value, selectData, multiple, onValueChange, selectedElements, updateInput, updateMultipleInput, ]); useEffect(() => { setIsOpen(open); }, [open]); useEffect(() => { if (isOpen) { setIsMounted(true); onTransitionShow(() => { setShowDropdown(true); setOpen?.(true); }); return; } if (!showDropdown) { return; } setShowDropdown(false); setOpen?.(false); document.activeElement === referenceElement && referenceElement.blur(); onTransitionHide(() => { setIsMounted(false); search && setQuery(""); }); }, [isOpen]); useEffect(() => { if (isMounted) { showDropdown ? onOpen?.() : onClose?.(); showDropdown && search && searchRef.current?.focus(); } }, [showDropdown]); const dropdownContainer = container ? document.querySelector(container) || document.body : document.body; return ( <> setIsOpen(!isOpen)} onKeyDown={handleKeyboard} value={inputValue} disabled={disabled} placeholder={ String(inputValue) ? undefined : label ? undefined : placeholder } label={label} readOnly size={size} theme={{ input: theme.selectInput, label: theme.selectLabel }} formWhite={contrast} aria-multiselectable={!!multiple} aria-disabled={!!disabled} aria-expanded={isOpen} role="listbox" /> {clearBtn && (inputValue.length > 0 || fakeValue) && ( { setInputValue(""); setSelectedElements([]); onValueChange?.(multiple ? [] : {}); }} > {clearIcon || ( )} )}
{selectIcon || ( )}
{isMounted && createPortal(
{search && (
) => setQuery(e.target.value) } ref={searchRef} type="text" role="searchbox" />
)}
{children}
, dropdownContainer )}
); }; export default TESelect;