import React, { type DetailedHTMLProps, type HTMLAttributes, type PropsWithChildren, } from "react"; import ReactDOM from "react-dom"; import { twMerge } from "tailwind-merge"; export const TWUIDropdownContentPositions = [ "left", "bottom-left", "top-left", "top", "bottom", "right", "bottom-right", "top-right", "center", ] as const; export type TWUI_DROPDOWN_PROPS = PropsWithChildren & DetailedHTMLProps, HTMLDivElement> & { target: React.ReactNode; contentWrapperProps?: DetailedHTMLProps< HTMLAttributes, HTMLDivElement >; targetWrapperProps?: DetailedHTMLProps< HTMLAttributes, HTMLDivElement >; debounce?: number; openDebounce?: number; hoverOpen?: boolean; above?: boolean; position?: (typeof TWUIDropdownContentPositions)[number]; topOffset?: number; externalSetOpen?: React.Dispatch>; externalOpen?: boolean; keepOpen?: boolean; disableClickActions?: boolean; }; /** * # Toggle Component * @className_wrapper twui-dropdown-wrapper * @className_wrapper twui-dropdown-target * @className_wrapper twui-dropdown-content * * @note use the class `cancel-link` to prevent popup open on click */ export default function Dropdown({ contentWrapperProps, targetWrapperProps, hoverOpen, above, debounce = 200, openDebounce = 200, target, position = "center", topOffset, externalSetOpen, keepOpen, disableClickActions, externalOpen, ...props }: TWUI_DROPDOWN_PROPS) { const [open, setOpen] = React.useState(externalOpen); const [mounted, setMounted] = React.useState(false); const [contentStyle, setContentStyle] = React.useState( {}, ); 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) { externalSetOpen?.(false); return setOpen(false); } if (closestWrapper && closestWrapper !== dropdownRef.current) { externalSetOpen?.(false); return setOpen(false); } }, []); React.useEffect(() => { if (keepOpen) return; document.addEventListener("click", handleClickOutside); return () => { document.removeEventListener("click", handleClickOutside); }; }, []); React.useEffect(() => { 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(timeoutRef.current); window.clearTimeout(openTimeoutRef.current); openTimeoutRef.current = setTimeout(() => { externalSetOpen?.(true); setOpen(true); }, openDebounce); }} onMouseLeave={(e) => { if (!hoverOpen) return; window.clearTimeout(openTimeoutRef.current); timeoutRef.current = setTimeout(() => { externalSetOpen?.(false); setOpen(false); }, debounce); }} onBlur={() => { window.clearTimeout(timeoutRef.current); }} ref={dropdownRef} >
{ const targetEl = e.target as HTMLElement | null; if (targetEl?.closest?.(".cancel-link")) return; if (disableClickActions) return; externalSetOpen?.(!open); setOpen(!open); }} className={twMerge( "cursor-pointer", "twui-dropdown-target", targetWrapperProps?.className, )} > {target}
{mounted && ReactDOM.createPortal(
{ 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, )}
); }