|
|
@@ -0,0 +1,139 @@
|
|
|
+import { useState, useRef, useEffect, type ReactNode, type CSSProperties } from "react";
|
|
|
+import styles from "./Select.module.css";
|
|
|
+import { createPortal } from "react-dom";
|
|
|
+export interface SelectOption {
|
|
|
+ id: string;
|
|
|
+ label: string;
|
|
|
+ [key: string]: any; // Allow any extra data (like icons, colors, etc.)
|
|
|
+}
|
|
|
+
|
|
|
+interface CustomSelectProps {
|
|
|
+ style?: CSSProperties;
|
|
|
+ className?: string;
|
|
|
+ options: SelectOption[];
|
|
|
+ value: string;
|
|
|
+ onChange: (value: string) => void;
|
|
|
+
|
|
|
+ // Custom renderers
|
|
|
+ renderValue?: (option: SelectOption | undefined) => ReactNode;
|
|
|
+ renderOption?: (option: SelectOption) => ReactNode;
|
|
|
+
|
|
|
+ // Styling
|
|
|
+ isDark?: boolean;
|
|
|
+ variant?: "simple" | "stacked";
|
|
|
+ placeholder?: string;
|
|
|
+}
|
|
|
+
|
|
|
+export default function Select({
|
|
|
+ style,
|
|
|
+ className,
|
|
|
+ options,
|
|
|
+ value,
|
|
|
+ onChange,
|
|
|
+ renderValue,
|
|
|
+ renderOption,
|
|
|
+ isDark = false,
|
|
|
+ variant = "simple",
|
|
|
+ placeholder = "Select...",
|
|
|
+}: CustomSelectProps) {
|
|
|
+ const [isOpen, setIsOpen] = useState(false);
|
|
|
+ const [menuCoords, setMenuCoords] = useState({ top: 0, left: 0, width: 0 });
|
|
|
+
|
|
|
+ const triggerRef = useRef<HTMLButtonElement>(null);
|
|
|
+ const menuRef = useRef<HTMLDivElement>(null);
|
|
|
+
|
|
|
+ const selectedOption = options.find((opt) => opt.id === value);
|
|
|
+
|
|
|
+ const toggleMenu = () => {
|
|
|
+ if (!isOpen && triggerRef.current) {
|
|
|
+ // Calculate exact screen position before opening
|
|
|
+ const rect = triggerRef.current.getBoundingClientRect();
|
|
|
+ setMenuCoords({
|
|
|
+ top: rect.bottom, // Place right below the button
|
|
|
+ left: rect.left,
|
|
|
+ width: rect.width, // Match the button's width
|
|
|
+ });
|
|
|
+ }
|
|
|
+ setIsOpen(!isOpen);
|
|
|
+ };
|
|
|
+ useEffect(() => {
|
|
|
+ if (!isOpen) return;
|
|
|
+
|
|
|
+ const handleClickOutside = (e: MouseEvent | PointerEvent) => {
|
|
|
+ const target = e.target as Node;
|
|
|
+ // If we clicked the trigger or inside the menu, do nothing
|
|
|
+ if (
|
|
|
+ (triggerRef.current && triggerRef.current.contains(target)) ||
|
|
|
+ (menuRef.current && menuRef.current.contains(target))
|
|
|
+ ) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // Otherwise, we clicked outside, so close it
|
|
|
+ setIsOpen(false);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleScroll = (e: Event) => {
|
|
|
+ if (menuRef.current && menuRef.current.contains(e.target as Node)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setIsOpen(false);
|
|
|
+ };
|
|
|
+ document.addEventListener("pointerdown", handleClickOutside, true);
|
|
|
+ document.addEventListener("mousedown", handleClickOutside, true);
|
|
|
+ // Use capture phase (true) to catch all scroll events anywhere on the page
|
|
|
+ window.addEventListener("scroll", handleScroll, true);
|
|
|
+ window.addEventListener("resize", () => setIsOpen(false));
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ document.removeEventListener("pointerdown", handleClickOutside, true);
|
|
|
+ document.removeEventListener("mousedown", handleClickOutside, true);
|
|
|
+ window.removeEventListener("scroll", handleScroll, true);
|
|
|
+ window.removeEventListener("resize", () => setIsOpen(false));
|
|
|
+ };
|
|
|
+ }, [isOpen]);
|
|
|
+
|
|
|
+ const menuContent = isOpen ? (
|
|
|
+ <div
|
|
|
+ className={`${styles.customSelectMenu} ${isDark ? "dark" : ""} ${variant === "simple" ? styles.menuSimple : styles.menuStacked}`}
|
|
|
+ style={{
|
|
|
+ position: "fixed",
|
|
|
+ top: `${menuCoords.top + 4}px`,
|
|
|
+ left: `${menuCoords.left}px`,
|
|
|
+ width: `${menuCoords.width}px`,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {options.map((option) => (
|
|
|
+ <div
|
|
|
+ key={option.id}
|
|
|
+ className={`${variant === "simple" ? styles.optionSimple : styles.optionStacked} ${
|
|
|
+ option.id === value ? "selected" : ""
|
|
|
+ }`}
|
|
|
+ onClick={() => {
|
|
|
+ onChange(option.id);
|
|
|
+ setIsOpen(false);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {renderOption ? renderOption(option) : option.label}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : null;
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <button
|
|
|
+ ref={triggerRef}
|
|
|
+ type="button"
|
|
|
+ className={`customSelectTrigger ${isOpen ? "isOpen" : ""} ${isDark ? "dark" : ""} ${className ?? ""}`}
|
|
|
+ style={style}
|
|
|
+ onClick={toggleMenu}
|
|
|
+ >
|
|
|
+ <div style={{ flex: 1, overflow: "hidden" }}>
|
|
|
+ {selectedOption ? (renderValue ? renderValue(selectedOption) : selectedOption.label) : placeholder}
|
|
|
+ </div>
|
|
|
+ <span className={styles.chevron}>{isOpen ? "▲" : "▼"}</span>
|
|
|
+ </button>
|
|
|
+ {/* DROPDOWN MENU */}
|
|
|
+ {isOpen && createPortal(menuContent, document.body)}
|
|
|
+ </>
|
|
|
+ );
|
|
|
+}
|