diff --git a/components/elements/Dropdown.tsx b/components/elements/Dropdown.tsx index 199f816..48a6b4b 100644 --- a/components/elements/Dropdown.tsx +++ b/components/elements/Dropdown.tsx @@ -3,6 +3,7 @@ import React, { type HTMLAttributes, type PropsWithChildren, } from "react"; +import ReactDOM from "react-dom"; import { twMerge } from "tailwind-merge"; export const TWUIDropdownContentPositions = [ @@ -65,15 +66,20 @@ export default function Dropdown({ ...props }: TWUI_DROPDOWN_PROPS) { const [open, setOpen] = React.useState(externalOpen); + const [mounted, setMounted] = React.useState(false); + const [contentStyle, setContentStyle] = React.useState( + {}, + ); - let timeout: any; - let openTimeout: any; + const timeoutRef = React.useRef(null); + const openTimeoutRef = React.useRef(null); const dropdownRef = React.useRef(null); const dropdownContentRef = React.useRef(null); const handleClickOutside = React.useCallback((e: MouseEvent) => { const targetEl = e.target as HTMLElement; + if (dropdownContentRef.current?.contains(targetEl)) return; const closestWrapper = targetEl.closest(".twui-dropdown-wrapper"); if (!closestWrapper) { @@ -98,6 +104,91 @@ export default function Dropdown({ setOpen(externalOpen); }, [externalOpen]); + React.useEffect(() => { + setMounted(true); + }, []); + + /** + * Content is portaled to `document.body` so it renders above + * everything else on the page. Position is computed from the + * wrapper's bounding rect and kept in sync on scroll/resize. + */ + const updateContentPosition = React.useCallback(() => { + const wrapperEl = dropdownRef.current; + if (!wrapperEl) return; + + const rect = wrapperEl.getBoundingClientRect(); + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + let style: React.CSSProperties; + + switch (position) { + case "left": + style = { + left: rect.right, + top: centerY, + transform: "translateY(-50%)", + }; + break; + case "right": + style = { + right: viewportWidth - rect.left, + top: centerY, + transform: "translateY(-50%)", + }; + break; + case "bottom-left": + style = { left: rect.left, top: rect.bottom }; + break; + case "bottom-right": + style = { right: viewportWidth - rect.right, top: rect.bottom }; + break; + case "top": + style = { + left: centerX, + bottom: viewportHeight - rect.top, + transform: "translateX(-50%)", + }; + break; + default: + style = { + left: centerX, + top: rect.bottom, + transform: "translateX(-50%)", + }; + break; + } + + if ( + above && + style.top !== undefined && + position !== "left" && + position !== "right" + ) { + delete style.top; + style.bottom = viewportHeight - rect.top; + } + + setContentStyle(style); + }, [position, above]); + + React.useLayoutEffect(() => { + if (!open) return; + + updateContentPosition(); + + window.addEventListener("scroll", updateContentPosition, true); + window.addEventListener("resize", updateContentPosition); + + return () => { + window.removeEventListener("scroll", updateContentPosition, true); + window.removeEventListener("resize", updateContentPosition); + }; + }, [open, updateContentPosition]); + return (
{ if (!hoverOpen) return; - window.clearTimeout(timeout); - window.clearTimeout(openTimeout); + window.clearTimeout(timeoutRef.current); + window.clearTimeout(openTimeoutRef.current); - openTimeout = setTimeout(() => { + openTimeoutRef.current = setTimeout(() => { externalSetOpen?.(true); setOpen(true); }, openDebounce); @@ -119,15 +210,15 @@ export default function Dropdown({ onMouseLeave={(e) => { if (!hoverOpen) return; - window.clearTimeout(openTimeout); + window.clearTimeout(openTimeoutRef.current); - timeout = setTimeout(() => { + timeoutRef.current = setTimeout(() => { externalSetOpen?.(false); setOpen(false); }, debounce); }} onBlur={() => { - window.clearTimeout(timeout); + window.clearTimeout(timeoutRef.current); }} ref={dropdownRef} > @@ -148,44 +239,35 @@ export default function Dropdown({ {target}
-
{ + if (!hoverOpen) return; + window.clearTimeout(timeoutRef.current); + }} + onBlur={() => { + if (!hoverOpen) return; + window.clearTimeout(timeoutRef.current); + }} + style={{ + ...contentStyle, + ...contentWrapperProps?.style, + }} + ref={dropdownContentRef} + > + {props.children} +
, + document.body, )} - onMouseEnter={() => { - if (!hoverOpen) return; - window.clearTimeout(timeout); - }} - onBlur={() => { - if (!hoverOpen) return; - window.clearTimeout(timeout); - }} - style={{ - // top: `calc(100% + ${topOffset || 0}px)`, - ...contentWrapperProps?.style, - }} - ref={dropdownContentRef} - > - {props.children} - ); }