diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx new file mode 100644 index 0000000..4281ae9 --- /dev/null +++ b/src/components/ui/Select.tsx @@ -0,0 +1,143 @@ +import React, { useState, useRef, useEffect } from "react"; +import { createPortal } from "react-dom"; + +export interface SelectOption { + value: string | number; + label: string; +} + +interface SelectProps { + value: string | number; + onChange: (value: string) => void; + options: SelectOption[]; + className?: string; + buttonClassName?: string; +} + +export const Select: React.FC = ({ + value, + onChange, + options, + className = "", + buttonClassName = "", +}) => { + const [isOpen, setIsOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState({}); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + buttonRef.current && + !buttonRef.current.contains(event.target as Node) && + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + // Calculate placement (Auto-placement + Fixed position for Portals) + useEffect(() => { + if (isOpen && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const spaceAbove = rect.top; + const dropdownHeight = 240; // Estimated max height + + let isUpwards = false; + if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) { + isUpwards = true; + } + + setDropdownStyle({ + position: "fixed", + top: isUpwards ? `${rect.top - 4}px` : `${rect.bottom + 4}px`, + left: `${rect.left}px`, + width: `${rect.width}px`, + transform: isUpwards ? "translateY(-100%)" : "none", + zIndex: 99999, // Ensure it's above all modals + }); + } + }, [isOpen]); + + // Close on window resize or scroll to avoid floating detachment + useEffect(() => { + const handleScrollOrResize = () => setIsOpen(false); + if (isOpen) { + window.addEventListener("resize", handleScrollOrResize); + window.addEventListener("scroll", handleScrollOrResize, true); + } + return () => { + window.removeEventListener("resize", handleScrollOrResize); + window.removeEventListener("scroll", handleScrollOrResize, true); + }; + }, [isOpen]); + + const selectedOption = options.find((o) => o.value === value) || options[0]; + + return ( +
+ + + {isOpen && + createPortal( +
+ {options.map((option) => ( +
{ + onChange(String(option.value)); + setIsOpen(false); + }} + className={`px-3 py-2 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors ${ + value === option.value + ? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium" + : "text-slate-700 dark:text-slate-300" + }`} + > + {option.label} +
+ ))} +
, + document.body + )} +
+ ); +};