import { useCallback, useEffect, useRef, useState } from "react"; import { IoCheckmark, IoChevronDown, IoClose } from "react-icons/io5"; import SimpleBar from "simplebar-react"; import "simplebar-react/dist/simplebar.min.css"; import "./Select.css"; function cx(...parts) { return parts.filter(Boolean).join(" "); } /** * Custom select with optional search. onChange receives a synthetic event * `{ target: { value, name } }` like a native select element. With `multiple`, * `value` and the emitted value are arrays and options toggle on click. * * @param {object} props * @param {string | string[]} props.value * @param {boolean} [props.multiple] * @param {(e: { target: { value: string, name?: string } }) => void} [props.onChange] * @param {{ value: string, label: string, disabled?: boolean, search?: string }[]} [props.options] * @param {string} [props.placeholder] * @param {boolean} [props.disabled] * @param {string} [props.id] * @param {boolean} [props.searchable] * @param {(option: object) => import('react').ReactNode} [props.renderOption] * @param {(option: object) => import('react').ReactNode} [props.renderSelected] * @param {string} [props.className] * @param {string} [props.name] */ export function Select({ value, onChange, options = [], placeholder = "Select an option", disabled = false, id, searchable = false, renderOption, renderSelected, className = "", name, multiple = false, }) { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const wrapperRef = useRef(null); const searchInputRef = useRef(null); const selectedValues = multiple ? Array.isArray(value) ? value : [] : [value]; const isSelected = (optionValue) => selectedValues.includes(optionValue); const selectedOption = multiple ? null : options.find((opt) => opt.value === value); const selectedOptions = multiple ? selectedValues.map( (v) => options.find((opt) => opt.value === v) ?? { value: v, label: v }, ) : []; const emit = (next) => onChange?.({ target: { value: next, name } }); const filteredOptions = searchable && searchTerm ? options.filter( (opt) => opt.label.toLowerCase().includes(searchTerm.toLowerCase()) || opt?.search?.toLowerCase()?.includes(searchTerm.toLowerCase()), ) : options; useEffect(() => { const handleClickOutside = (event) => { if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { setIsOpen(false); setSearchTerm(""); } }; if (isOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen]); useEffect(() => { if (isOpen && searchable && searchInputRef.current) { searchInputRef.current.focus(); } }, [isOpen, searchable]); const handleKeyDown = useCallback( (event) => { if (disabled) return; switch (event.key) { case "Enter": case " ": if (!isOpen) { event.preventDefault(); setIsOpen(true); } break; case "Escape": setIsOpen(false); setSearchTerm(""); break; case "ArrowDown": if (!isOpen) { event.preventDefault(); setIsOpen(true); } break; default: break; } }, [disabled, isOpen], ); const handleTriggerClick = () => { if (!disabled) { setIsOpen(!isOpen); if (isOpen) { setSearchTerm(""); } } }; const handleOptionClick = (option) => { if (option.disabled) return; if (multiple) { emit( isSelected(option.value) ? selectedValues.filter((v) => v !== option.value) : [...selectedValues, option.value], ); return; } emit(option.value); setIsOpen(false); setSearchTerm(""); }; const handleTagRemove = (event, optionValue) => { event.stopPropagation(); if (disabled) return; emit(selectedValues.filter((v) => v !== optionValue)); }; const handleSearchChange = (e) => { setSearchTerm(e.target.value); }; const renderSelectedContent = () => { if (searchable && isOpen) { return ( e.stopPropagation()} onFocus={(e) => e.stopPropagation()} /> ); } if (multiple) { if (selectedOptions.length === 0) { return {placeholder}; } return ( {selectedOptions.map((option) => ( {renderSelected ? renderSelected(option) : option.label} handleTagRemove(e, option.value)} > ))} ); } if (!selectedOption) { return {placeholder}; } if (renderSelected) { return renderSelected(selectedOption); } return ( {selectedOption.label} ); }; const renderOptionContent = (option) => { if (renderOption) { return renderOption(option); } return {option.label}; }; return (
{filteredOptions.length === 0 ? (
No options found
) : ( filteredOptions.map((option) => (
handleOptionClick(option)} role="option" aria-selected={isSelected(option.value)} > {renderOptionContent(option)} {multiple && isSelected(option.value) ? ( ) : null}
)) )}
); }