This repository has been archived on 2026-10-08. You can view files and clone it, but cannot push or open issues or pull requests.

282 lines
7.6 KiB
JavaScript

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 (
<input
ref={searchInputRef}
type="text"
className="select-search-input"
placeholder={placeholder}
value={searchTerm}
onChange={handleSearchChange}
onClick={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
/>
);
}
if (multiple) {
if (selectedOptions.length === 0) {
return <span className="select-placeholder">{placeholder}</span>;
}
return (
<span className="select-tags">
{selectedOptions.map((option) => (
<span key={option.value} className="select-tag">
<span className="select-tag-label">
{renderSelected ? renderSelected(option) : option.label}
</span>
<span
role="button"
tabIndex={-1}
aria-label={`Remove ${option.label}`}
className="select-tag-remove"
onClick={(e) => handleTagRemove(e, option.value)}
>
<IoClose size={12} />
</span>
</span>
))}
</span>
);
}
if (!selectedOption) {
return <span className="select-placeholder">{placeholder}</span>;
}
if (renderSelected) {
return renderSelected(selectedOption);
}
return (
<span className="select-selected-label">{selectedOption.label}</span>
);
};
const renderOptionContent = (option) => {
if (renderOption) {
return renderOption(option);
}
return <span className="select-option-label">{option.label}</span>;
};
return (
<div
ref={wrapperRef}
className={cx("select-wrap", className)}
onKeyDown={handleKeyDown}
>
<button
type="button"
id={id}
className={cx(
"select-trigger",
isOpen && "select-trigger--open",
multiple && "select-trigger--multiple",
)}
onClick={handleTriggerClick}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<div className="select-value">{renderSelectedContent()}</div>
<span className="select-chevron" aria-hidden>
<IoChevronDown size={18} />
</span>
</button>
<div
className={cx("select-dropdown", isOpen && "select-dropdown--open")}
role="listbox"
>
<SimpleBar className="select-options">
{filteredOptions.length === 0 ? (
<div className="select-empty">No options found</div>
) : (
filteredOptions.map((option) => (
<div
key={option.value}
className={cx(
"select-option",
isSelected(option.value) && "select-option--selected",
option.disabled && "select-option--disabled",
)}
onClick={() => handleOptionClick(option)}
role="option"
aria-selected={isSelected(option.value)}
>
{renderOptionContent(option)}
{multiple && isSelected(option.value) ? (
<IoCheckmark className="select-option-check" size={16} />
) : null}
</div>
))
)}
</SimpleBar>
</div>
</div>
);
}