"use client"; import * as React from "react"; import { createPortal } from "react-dom"; import { Check, ChevronDown } from "lucide-react"; import { motion, AnimatePresence, HTMLMotionProps } from "framer-motion"; import { cn } from "../../lib/utils"; // Assuming you have this utility function interface SelectContextType { value: string; onValueChange: (value: string) => void; open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; searchQuery: string; setSearchQuery: (query: string) => void; } const SelectContext = React.createContext( undefined ); interface SelectProps { children: React.ReactNode; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; disabled?: boolean; } const Select: React.FC = ({ children, defaultValue = "", value, onValueChange, defaultOpen = false, open, onOpenChange, disabled = false, }) => { const [selectedValue, setSelectedValue] = React.useState( value || defaultValue ); const [isOpen, setIsOpen] = React.useState(open || defaultOpen); const triggerRef = React.useRef(null); const [searchQuery, setSearchQuery] = React.useState(""); React.useEffect(() => { if (value !== undefined) { setSelectedValue(value); } }, [value]); React.useEffect(() => { if (open !== undefined) { setIsOpen(open); } }, [open]); React.useEffect(() => { if (!isOpen) { setSearchQuery(""); } }, [isOpen]); const handleValueChange = React.useCallback( (newValue: string) => { if (value === undefined) { setSelectedValue(newValue); } onValueChange?.(newValue); }, [onValueChange, value] ); const handleOpenChange = React.useCallback( (newOpen: boolean) => { if (disabled) return; if (open === undefined) { setIsOpen(newOpen); } onOpenChange?.(newOpen); }, [onOpenChange, open, disabled] ); return ( {children} ); }; const SelectGroup: React.FC> = ({ children, ...props }) => { return (
{children}
); }; SelectGroup.displayName = "SelectGroup"; interface SelectValueProps extends React.HTMLAttributes { placeholder?: string; } const SelectValue = React.forwardRef( ({ className, placeholder, children, ...props }, ref) => { const context = React.useContext(SelectContext); if (!context) { throw new Error("SelectValue must be used within a Select"); } // This is a bit of a hack to get the children from the parent Select component. // A better implementation would involve passing a map of values to display labels via context. // For simplicity, we are assuming children are passed directly or can be inferred. const parentChildren = (context as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED ?.children || children; let displayValue: React.ReactNode = null; const findDisplayValue = (nodes: React.ReactNode) => { React.Children.forEach(nodes, (node) => { if (!React.isValidElement(node)) return; if (displayValue) return; // Check if it's a SelectItem if ( (node.type as any).displayName === "SelectItem" && node.props.value === context.value ) { displayValue = node.props.children; } // Check if it's a SelectGroup and recurse else if ((node.type as any).displayName === "SelectGroup") { findDisplayValue(node.props.children); } }); }; findDisplayValue(parentChildren); const content = displayValue || context.value || placeholder; return ( {content || ( Select an option )} ); } ); SelectValue.displayName = "SelectValue"; interface SelectTriggerProps extends React.ButtonHTMLAttributes {} const SelectTrigger = React.forwardRef( ({ className, children, ...props }, ref) => { const context = React.useContext(SelectContext); if (!context) { throw new Error("SelectTrigger must be used within a Select"); } const { open, setOpen, triggerRef, searchQuery, setSearchQuery } = context; const searchInputRef = React.useRef(null); React.useEffect(() => { if (open && searchInputRef.current) { searchInputRef.current.focus(); } }, [open]); React.useImperativeHandle(ref, () => triggerRef.current!, [triggerRef]); return ( ); } ); SelectTrigger.displayName = "SelectTrigger"; const SelectScrollUpButton: React.FC> = ( props ) =>
; SelectScrollUpButton.displayName = "SelectScrollUpButton"; const SelectScrollDownButton: React.FC> = ( props ) =>
; SelectScrollDownButton.displayName = "SelectScrollDownButton"; // *** FIX APPLIED HERE: Replaced React.HTMLAttributes with HTMLMotionProps *** interface SelectContentProps extends Omit, "children"> { children: React.ReactNode; // Explicitly define children as standard ReactNode position?: "popper" | "item-aligned"; align?: "start" | "center" | "end"; sideOffset?: number; } const SelectContent = React.forwardRef( ( { className, children, position = "popper", align = "start", sideOffset = 4, ...props }, ref ) => { const context = React.useContext(SelectContext); if (!context) { throw new Error("SelectContent must be used within a Select"); } const { open, setOpen, triggerRef, searchQuery } = context; const contentRef = React.useRef(null); const [calculatedStyle, setCalculatedStyle] = React.useState({}); const [currentSide, setCurrentSide] = React.useState<"top" | "bottom">( "bottom" ); React.useEffect(() => { if (!open || !triggerRef.current) return; const updatePosition = () => { if (!triggerRef.current) return; // Guard against null ref const triggerRect = triggerRef.current.getBoundingClientRect(); const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; const spaceBelow = viewportHeight - triggerRect.bottom; const spaceAbove = triggerRect.top; const preferredMaxHeight = 224; // A common max-height for dropdowns (e.g., tailwind's h-56) // Decide whether to show the dropdown below or above the trigger const showBelow = spaceBelow >= preferredMaxHeight || spaceBelow > spaceAbove; const newSide = showBelow ? "bottom" : "top"; setCurrentSide(newSide); // Define the styles that will be applied const newStyles: React.CSSProperties = { position: "absolute", width: `${triggerRect.width}px`, }; // --- START OF FIX --- // This is the key change. We now calculate position differently // for 'top' and 'bottom' to ensure it's always attached correctly. if (newSide === "bottom") { const availableHeight = spaceBelow - sideOffset - 8; // 8px for margin newStyles.maxHeight = `${Math.min(preferredMaxHeight, Math.max(0, availableHeight))}px`; newStyles.top = `${triggerRect.bottom + window.scrollY + sideOffset}px`; } else { // Position above the trigger const availableHeight = spaceAbove - sideOffset - 8; // 8px for margin newStyles.maxHeight = `${Math.min(preferredMaxHeight, Math.max(0, availableHeight))}px`; // By setting `bottom`, we anchor the dropdown's bottom edge to the trigger's top edge. // This solves the gap issue completely. newStyles.bottom = `${viewportHeight - triggerRect.top - window.scrollY + sideOffset}px`; } // --- END OF FIX --- // Handle horizontal alignment let left = triggerRect.left; if (align === "center") { // This calculation was slightly off, corrected to center based on content width if known, // but for a select, centering on trigger is usually sufficient. left = triggerRect.left + (triggerRect.width / 2) - (triggerRect.width / 2); // Assumes content width = trigger width } else if (align === "end") { left = triggerRect.right - triggerRect.width; } // Prevent overflow from the right edge of the viewport if (left + triggerRect.width > viewportWidth) { left = viewportWidth - triggerRect.width - 8; // 8px margin } // Prevent overflow from the left edge of the viewport if (left < 0) { left = 8; // 8px margin } newStyles.left = `${left + window.scrollX}px`; setCalculatedStyle(newStyles); }; updatePosition(); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); return () => { window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; }, [open, align, sideOffset, triggerRef]); React.useEffect(() => { if (!open) return; const handleClickOutside = (e: MouseEvent) => { if ( contentRef.current && !contentRef.current.contains(e.target as Node) && triggerRef.current && !triggerRef.current.contains(e.target as Node) ) { setOpen(false); } }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [open, setOpen, triggerRef]); const combinedRef = React.useCallback( (node: HTMLDivElement | null) => { contentRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { (ref as React.MutableRefObject).current = node; } }, [ref] ); const filteredChildren = React.useMemo(() => { if (!searchQuery) { return children; } const lowerCaseQuery = searchQuery.toLowerCase(); const getChildText = (child: React.ReactNode): string => { if (typeof child === "string" || typeof child === "number") { return child.toString(); } if (React.isValidElement(child) && child.props.children) { return React.Children.map(child.props.children, getChildText).join( "" ); } return ""; }; return React.Children.map(children, (child) => { if (!React.isValidElement(child)) { return child; } if ((child.type as any).displayName === "SelectGroup") { const matchedItems = React.Children.toArray( child.props.children ).filter((groupChild) => { if ( React.isValidElement(groupChild) && (groupChild.type as any).displayName === "SelectItem" ) { const text = getChildText(groupChild.props.children); return text.toLowerCase().includes(lowerCaseQuery); } return false; }); if (matchedItems.length > 0) { return React.cloneElement(child, { ...child.props, children: matchedItems, }); } return null; } if ((child.type as any).displayName === "SelectItem") { const text = getChildText(child.props.children); return text.toLowerCase().includes(lowerCaseQuery) ? child : null; } return child; }); }, [children, searchQuery]); // Check if there are any children to render after filtering const hasVisibleChildren = React.Children.count(filteredChildren) > 0; return createPortal( {open && (
{hasVisibleChildren ? ( filteredChildren ) : (
No results found.
)}
)}
, document.body ); } ); SelectContent.displayName = "SelectContent"; const SelectLabel = React.forwardRef< HTMLSpanElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( )); SelectLabel.displayName = "SelectLabel"; interface SelectItemProps extends React.HTMLAttributes { value: string; disabled?: boolean; } const SelectItem = React.forwardRef( ({ className, children, value, disabled = false, ...props }, ref) => { const context = React.useContext(SelectContext); if (!context) { throw new Error("SelectItem must be used within a Select"); } const { value: selectedValue, onValueChange, setOpen } = context; const isSelected = selectedValue === value; const handleSelect = (e: React.MouseEvent) => { if (disabled) return; e.preventDefault(); e.stopPropagation(); onValueChange(value); setTimeout(() => setOpen(false), 50); // Small delay to show selection }; return (
{ if (e.key === "Enter" || e.key === " ") { handleSelect(e as any); } }} aria-selected={isSelected} data-disabled={disabled} role="option" tabIndex={disabled ? -1 : 0} {...props} > {isSelected && } {children}
); } ); SelectItem.displayName = "SelectItem"; const SelectSeparator = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); SelectSeparator.displayName = "SelectSeparator"; export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, };