282 lines
7.6 KiB
JavaScript
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>
|
|
);
|
|
}
|