import React, { useEffect, useRef, useState, type CSSProperties } from "react"; type PopoverAnchor = | { kind: "point"; x: number; y: number } | { kind: "element"; element: HTMLElement }; type PopoverSide = "bottom" | "right"; type PopoverAlign = "start" | "end" | "center"; interface UseViewportPopoverOptions { anchor: PopoverAnchor; width: number; estimatedHeight: number; onClose: () => void; side?: PopoverSide; align?: PopoverAlign; offset?: number; viewportPadding?: number; ignoreElements?: Array; ignoreSelectors?: string[]; ignoreScrollContainers?: React.RefObject[]; zIndex?: number; } export function useViewportPopover({ anchor, width, estimatedHeight, onClose, side = "bottom", align = "start", offset = 8, viewportPadding = 16, ignoreElements = [], ignoreSelectors = [], ignoreScrollContainers, zIndex = 9998, }: UseViewportPopoverOptions) { const ref = useRef(null); const frameRef = useRef(null); const computeStyle = (): CSSProperties => { if (typeof window === "undefined") { return { position: "fixed", left: viewportPadding, top: viewportPadding, width, zIndex, }; } let left = 0; let top = 0; if (anchor.kind === "element") { const rect = anchor.element.getBoundingClientRect(); if (side === "right") { left = rect.right + offset; if (align === "end") { top = rect.bottom - estimatedHeight; } else if (align === "center") { top = rect.top + rect.height / 2 - estimatedHeight / 2; } else { top = rect.top; } } else { left = rect.left; if (align === "end") { left = rect.right - width; } else if (align === "center") { left = rect.left + rect.width / 2 - width / 2; } top = rect.bottom + offset; } } else { left = anchor.x; top = anchor.y + offset; if (align === "end") { left = anchor.x - width; } else if (align === "center") { left = anchor.x - width / 2; } } const measuredWidth = ref.current?.offsetWidth ?? width; const measuredHeight = ref.current?.offsetHeight ?? estimatedHeight; const maxLeft = Math.max(viewportPadding, window.innerWidth - measuredWidth - viewportPadding); const maxTop = Math.max(viewportPadding, window.innerHeight - measuredHeight - viewportPadding); return { position: "fixed", left: Math.min(Math.max(left, viewportPadding), maxLeft), top: Math.min(Math.max(top, viewportPadding), maxTop), width, zIndex, }; }; const [style, setStyle] = useState(() => computeStyle()); useEffect(() => { function handlePointerDown(event: PointerEvent) { const target = event.target; if (!(target instanceof Node)) { return; } if (ref.current?.contains(target)) { return; } if (ignoreElements.some((element) => element?.contains(target))) { return; } if ( target instanceof Element && ignoreSelectors.some((selector) => target.closest(selector) !== null) ) { return; } onClose(); } function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { onClose(); } } document.addEventListener("pointerdown", handlePointerDown, true); window.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("pointerdown", handlePointerDown, true); window.removeEventListener("keydown", handleEscape); }; }, [ignoreElements, ignoreSelectors, onClose]); useEffect(() => { setStyle(computeStyle()); }, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]); useEffect(() => { const element = ref.current; if (!element || typeof ResizeObserver === "undefined") { return; } const observer = new ResizeObserver(() => { setStyle(computeStyle()); }); observer.observe(element); return () => { observer.disconnect(); }; }, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]); useEffect(() => { const closeOnViewportChange = anchor.kind === "point"; function cancelScheduledFrame() { if (frameRef.current === null) return; cancelAnimationFrame(frameRef.current); frameRef.current = null; } function updateOrClose() { if (anchor.kind === "element" && !anchor.element.isConnected) { onClose(); return; } setStyle(computeStyle()); } function scheduleUpdate(reason: "scroll" | "resize") { if (frameRef.current !== null) return; frameRef.current = requestAnimationFrame(() => { frameRef.current = null; updateOrClose(); }); } updateOrClose(); const handleScroll = (event: Event) => { if (closeOnViewportChange) { const scrollTarget = (event as Event).target; if ( ignoreScrollContainers?.some( (r) => r.current != null && scrollTarget instanceof Node && r.current.contains(scrollTarget), ) ) { return; } cancelScheduledFrame(); onClose(); return; } scheduleUpdate("scroll"); }; const handleResize = () => { if (closeOnViewportChange) { cancelScheduledFrame(); onClose(); return; } scheduleUpdate("resize"); }; window.addEventListener("scroll", handleScroll, true); window.addEventListener("resize", handleResize, { passive: true }); window.visualViewport?.addEventListener("resize", handleResize, { passive: true }); window.visualViewport?.addEventListener("scroll", handleScroll, { passive: true }); return () => { cancelScheduledFrame(); window.removeEventListener("scroll", handleScroll, true); window.removeEventListener("resize", handleResize); window.visualViewport?.removeEventListener("resize", handleResize); window.visualViewport?.removeEventListener("scroll", handleScroll); }; }, [ align, anchor, estimatedHeight, ignoreScrollContainers, offset, onClose, side, viewportPadding, width, zIndex, ]); return { ref, style }; }