203 lines
6.7 KiB
React
203 lines
6.7 KiB
React
|
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||
|
|
import { IoIosArrowDown, IoIosArrowUp, IoIosCloseCircle } from "react-icons/io";
|
||
|
|
|
||
|
|
const CellSelect = memo(({
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
onKeyDown,
|
||
|
|
dataCell,
|
||
|
|
options,
|
||
|
|
hasError,
|
||
|
|
placeholder = "Select option",
|
||
|
|
onClick
|
||
|
|
}) => {
|
||
|
|
const [isOpen, setIsOpen] = useState(false);
|
||
|
|
const [dropdownDirection, setDropdownDirection] = useState("down");
|
||
|
|
const [searchTerm, setSearchTerm] = useState("");
|
||
|
|
const selectRef = useRef(null);
|
||
|
|
const dropdownRef = useRef(null);
|
||
|
|
const searchInputRef = useRef(null);
|
||
|
|
|
||
|
|
// Filter options based on search term
|
||
|
|
const filteredOptions = options?.filter(option =>
|
||
|
|
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||
|
|
) || [];
|
||
|
|
|
||
|
|
// Calculate dropdown position
|
||
|
|
const calculateDropdownPosition = useCallback(() => {
|
||
|
|
if (!selectRef.current) return "down";
|
||
|
|
const rect = selectRef.current.getBoundingClientRect();
|
||
|
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||
|
|
const spaceAbove = rect.top;
|
||
|
|
return (spaceBelow < 200 && spaceAbove > spaceBelow) ? "up" : "down";
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Close dropdown on outside click or scroll
|
||
|
|
useEffect(() => {
|
||
|
|
if (!isOpen) return;
|
||
|
|
|
||
|
|
const handler = e => {
|
||
|
|
if (
|
||
|
|
!dropdownRef.current?.contains(e.target) &&
|
||
|
|
!selectRef.current?.contains(e.target)
|
||
|
|
) {
|
||
|
|
setIsOpen(false);
|
||
|
|
// Reset search term when closing dropdown
|
||
|
|
setSearchTerm("");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const scrollHandler = e => {
|
||
|
|
if (
|
||
|
|
dropdownRef.current &&
|
||
|
|
!dropdownRef.current.contains(e.target) &&
|
||
|
|
selectRef.current &&
|
||
|
|
!selectRef.current.contains(e.target)
|
||
|
|
) {
|
||
|
|
setIsOpen(false);
|
||
|
|
setSearchTerm("");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
document.addEventListener("mousedown", handler);
|
||
|
|
window.addEventListener("scroll", scrollHandler, true);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
document.removeEventListener("mousedown", handler);
|
||
|
|
window.removeEventListener("scroll", scrollHandler, true);
|
||
|
|
};
|
||
|
|
}, [isOpen]);
|
||
|
|
|
||
|
|
// Focus search input when dropdown opens
|
||
|
|
useEffect(() => {
|
||
|
|
if (isOpen && searchInputRef.current) {
|
||
|
|
searchInputRef.current.focus();
|
||
|
|
}
|
||
|
|
}, [isOpen]);
|
||
|
|
|
||
|
|
// Update dropdown direction
|
||
|
|
useEffect(() => {
|
||
|
|
if (isOpen) setDropdownDirection(calculateDropdownPosition());
|
||
|
|
}, [isOpen, calculateDropdownPosition]);
|
||
|
|
|
||
|
|
// Keyboard navigation
|
||
|
|
const handleKeyDown = e => {
|
||
|
|
if (!isOpen && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||
|
|
e.preventDefault();
|
||
|
|
onKeyDown(e);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isOpen && ["ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||
|
|
e.preventDefault();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (e.key === "Enter") {
|
||
|
|
e.preventDefault();
|
||
|
|
if (isOpen && filteredOptions.length > 0) {
|
||
|
|
// Select the first option when pressing Enter
|
||
|
|
handleSelectChange(filteredOptions[0].value);
|
||
|
|
} else {
|
||
|
|
setIsOpen(v => !v);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (e.key === "Escape") {
|
||
|
|
e.preventDefault();
|
||
|
|
setIsOpen(false);
|
||
|
|
setSearchTerm("");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Allow typing to search when dropdown is open
|
||
|
|
if (isOpen && e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||
|
|
setSearchTerm(prev => prev + e.key);
|
||
|
|
e.preventDefault();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
onKeyDown(e);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSelectChange = v => {
|
||
|
|
onChange({ target: { value: v } });
|
||
|
|
setIsOpen(false);
|
||
|
|
setSearchTerm(""); // Clear search term after selection
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSearchChange = e => {
|
||
|
|
setSearchTerm(e.target.value);
|
||
|
|
};
|
||
|
|
|
||
|
|
const clearSearch = () => {
|
||
|
|
setSearchTerm("");
|
||
|
|
if (searchInputRef.current) {
|
||
|
|
searchInputRef.current.focus();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const selectedOption = options?.find(opt => opt.value === value);
|
||
|
|
const displayValue = selectedOption ? selectedOption.label : placeholder;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="custom-select-container" ref={selectRef}>
|
||
|
|
<div
|
||
|
|
className={`custom-select-trigger${hasError ? " error" : ""}${isOpen ? " open" : ""}`}
|
||
|
|
onClick={e => {
|
||
|
|
setIsOpen(v => !v);
|
||
|
|
onClick?.(e);
|
||
|
|
}}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
data-cell={dataCell}
|
||
|
|
tabIndex={0}
|
||
|
|
>
|
||
|
|
<span className={!value ? "placeholder-text" : ""}>
|
||
|
|
{isOpen ? (
|
||
|
|
<input
|
||
|
|
ref={searchInputRef}
|
||
|
|
className="search-input"
|
||
|
|
value={searchTerm}
|
||
|
|
onChange={handleSearchChange}
|
||
|
|
onClick={e => e.stopPropagation()}
|
||
|
|
placeholder={selectedOption?.label || placeholder}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
displayValue
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
<span className="dropdown-arrow">
|
||
|
|
{isOpen ? <IoIosArrowUp /> : <IoIosArrowDown />}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
{isOpen && (
|
||
|
|
<div
|
||
|
|
className={`custom-select-dropdown ${dropdownDirection}`}
|
||
|
|
ref={dropdownRef}
|
||
|
|
style={dropdownDirection === "up" ? { bottom: "100%", top: "auto" } : {}}
|
||
|
|
>
|
||
|
|
<div className="dropdown-content">
|
||
|
|
{filteredOptions.length === 0 ? (
|
||
|
|
<div className="dropdown-option disabled">
|
||
|
|
{searchTerm ? "No matches found" : "No data"}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
filteredOptions.map(option => (
|
||
|
|
<div
|
||
|
|
key={option.value}
|
||
|
|
className={`dropdown-option${value === option.value ? " selected" : ""}`}
|
||
|
|
onClick={() => handleSelectChange(option.value)}
|
||
|
|
>
|
||
|
|
{option.label}
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
export default CellSelect;
|