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)}
>