diff --git a/web/src/components/Dropdown.tsx b/web/src/components/Dropdown.tsx index e822632b4eb..2c5c4719efb 100644 --- a/web/src/components/Dropdown.tsx +++ b/web/src/components/Dropdown.tsx @@ -10,6 +10,8 @@ import { import { ChevronDownIcon } from "./icons/icons"; import { FiCheck, FiChevronDown } from "react-icons/fi"; import { Popover } from "./popover/Popover"; +import { createPortal } from "react-dom"; +import { useDropdownPosition } from "@/lib/dropdown"; export interface Option { name: string; @@ -60,6 +62,7 @@ export function SearchMultiSelectDropdown({ const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const dropdownRef = useRef(null); + const dropdownMenuRef = useRef(null); const handleSelect = (option: StringOrNumberOption) => { onSelect(option); @@ -75,7 +78,9 @@ export function SearchMultiSelectDropdown({ const handleClickOutside = (event: MouseEvent) => { if ( dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) + !dropdownRef.current.contains(event.target as Node) && + dropdownMenuRef.current && + !dropdownMenuRef.current.contains(event.target as Node) ) { setIsOpen(false); } @@ -87,105 +92,103 @@ export function SearchMultiSelectDropdown({ }; }, []); + useDropdownPosition({ isOpen, dropdownRef, dropdownMenuRef }); + return ( -
+
) => { - if (!searchTerm) { + setSearchTerm(e.target.value); + if (e.target.value) { setIsOpen(true); - } - if (!e.target.value) { + } else { setIsOpen(false); } - setSearchTerm(e.target.value); }} onFocus={() => setIsOpen(true)} className={`inline-flex - justify-between - w-full - px-4 - py-2 - text-sm - bg-background - border - border-border - rounded-md - shadow-sm - `} - onClick={(e) => e.stopPropagation()} + justify-between + w-full + px-4 + py-2 + text-sm + bg-background + border + border-border + rounded-md + shadow-sm + `} />
- {isOpen && ( -
+ {isOpen && + createPortal(
- {filteredOptions.length ? ( - filteredOptions.map((option, index) => - itemComponent ? ( -
{ - setIsOpen(false); - handleSelect(option); - }} - > - {itemComponent({ option })} -
- ) : ( - +
+ {filteredOptions.length ? ( + filteredOptions.map((option, index) => + itemComponent ? ( +
{ + handleSelect(option); + }} + > + {itemComponent({ option })} +
+ ) : ( + + ) ) - ) - ) : ( - - )} -
-
- )} + ) : ( + + )} +
+
, + document.body + )}
); } diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx index 4582ed8a558..05886975088 100644 --- a/web/src/components/Modal.tsx +++ b/web/src/components/Modal.tsx @@ -66,11 +66,21 @@ export function Modal({ e.stopPropagation(); } }} - className={`bg-background text-emphasis rounded shadow-2xl - transform transition-all duration-300 ease-in-out + className={` + bg-background + text-emphasis + rounded + shadow-2xl + transform + transition-all + duration-300 + ease-in-out + relative + overflow-visible ${width ?? "w-11/12 max-w-4xl"} ${noPadding ? "" : "p-10"} - ${className || ""}`} + ${className || ""} + `} > {onOutsideClick && !hideCloseButton && (
diff --git a/web/src/lib/dropdown.ts b/web/src/lib/dropdown.ts new file mode 100644 index 00000000000..b4fcf42d68e --- /dev/null +++ b/web/src/lib/dropdown.ts @@ -0,0 +1,49 @@ +import { RefObject, useCallback, useEffect } from "react"; + +interface DropdownPositionProps { + isOpen: boolean; + dropdownRef: RefObject; + dropdownMenuRef: RefObject; +} + +// This hook manages the positioning of a dropdown menu relative to its trigger element. +// It ensures the menu is positioned correctly, adjusting for viewport boundaries and scroll position. +// Also adds event listeners for window resize and scroll to update the position dynamically. +export const useDropdownPosition = ({ + isOpen, + dropdownRef, + dropdownMenuRef, +}: DropdownPositionProps) => { + const updateMenuPosition = useCallback(() => { + if (isOpen && dropdownRef.current && dropdownMenuRef.current) { + const rect = dropdownRef.current.getBoundingClientRect(); + const menuRect = dropdownMenuRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + + let top = rect.bottom + window.scrollY; + + if (top + menuRect.height > viewportHeight) { + top = rect.top + window.scrollY - menuRect.height; + } + + dropdownMenuRef.current.style.position = "absolute"; + dropdownMenuRef.current.style.top = `${top}px`; + dropdownMenuRef.current.style.left = `${rect.left + window.scrollX}px`; + dropdownMenuRef.current.style.width = `${rect.width}px`; + dropdownMenuRef.current.style.zIndex = "10000"; + } + }, [isOpen, dropdownRef, dropdownMenuRef]); + + useEffect(() => { + updateMenuPosition(); + window.addEventListener("resize", updateMenuPosition); + window.addEventListener("scroll", updateMenuPosition); + + return () => { + window.removeEventListener("resize", updateMenuPosition); + window.removeEventListener("scroll", updateMenuPosition); + }; + }, [isOpen, updateMenuPosition]); + + return updateMenuPosition; +};