| 1 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 2 | import type { CSSProperties, ReactNode, RefObject } from "react"; |
| 3 | import { createPortal } from "react-dom"; |
| 4 | |
| 5 | type PopoverPosition = { |
| 6 | left: number; |
| 7 | top: number; |
| 8 | }; |
| 9 | type PopoverPhase = "closed" | "open" | "closing"; |
| 10 | |
| 11 | const EDGE_GAP = 8; |
| 12 | const DEFAULT_OFFSET = 8; |
| 13 | const MAX_INITIAL_POSITION_RETRY_FRAMES = 8; |
| 14 | export const ANCHORED_POPOVER_CLOSE_MS = 140; |
| 15 | |
| 16 | function clamp(value: number, min: number, max: number): number { |
| 17 | return Math.min(Math.max(value, min), max); |
| 18 | } |
| 19 | |
| 20 | function samePosition(a: PopoverPosition | null, b: PopoverPosition): boolean { |
| 21 | return !!a && Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5; |
| 22 | } |
| 23 | |
| 24 | function calculatePosition( |
| 25 | anchor: DOMRect, |
| 26 | menu: DOMRect, |
| 27 | align: "start" | "end", |
| 28 | offset: number, |
| 29 | placement: "auto" | "bottom", |
| 30 | ): PopoverPosition { |
| 31 | const viewportWidth = window.innerWidth; |
| 32 | const viewportHeight = window.innerHeight; |
| 33 | const preferredTop = anchor.top - menu.height - offset; |
| 34 | const fallbackTop = anchor.bottom + offset; |
| 35 | const top = placement === "bottom" |
| 36 | ? Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) |
| 37 | : preferredTop >= EDGE_GAP |
| 38 | ? preferredTop |
| 39 | : Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)); |
| 40 | const rawLeft = align === "end" ? anchor.right - menu.width : anchor.left; |
| 41 | const left = clamp(rawLeft, EDGE_GAP, Math.max(EDGE_GAP, viewportWidth - menu.width - EDGE_GAP)); |
| 42 | return { left, top: clamp(top, EDGE_GAP, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) }; |
| 43 | } |
| 44 | |
| 45 | export function AnchoredPopover({ |
| 46 | open, |
| 47 | anchorRef, |
| 48 | onClose, |
| 49 | className, |
| 50 | children, |
| 51 | align = "start", |
| 52 | offset = DEFAULT_OFFSET, |
| 53 | placement = "auto", |
| 54 | style, |
| 55 | closing = false, |
| 56 | }: { |
| 57 | open: boolean; |
| 58 | anchorRef: RefObject<HTMLElement | null>; |
| 59 | onClose: () => void; |
| 60 | className: string; |
| 61 | children: ReactNode; |
| 62 | align?: "start" | "end"; |
| 63 | offset?: number; |
| 64 | placement?: "auto" | "bottom"; |
| 65 | style?: CSSProperties; |
| 66 | closing?: boolean; |
| 67 | }) { |
| 68 | const [phase, setPhase] = useState<PopoverPhase>(open ? "open" : "closed"); |
| 69 | const [position, setPosition] = useState<PopoverPosition | null>(null); |
| 70 | const popoverRef = useRef<HTMLDivElement>(null); |
| 71 | const phaseRef = useRef<PopoverPhase>(phase); |
| 72 | const positionRef = useRef<PopoverPosition | null>(position); |
| 73 | |
| 74 | useLayoutEffect(() => { |
| 75 | let id: number | undefined; |
| 76 | if (open) { |
| 77 | phaseRef.current = "open"; |
| 78 | setPhase("open"); |
| 79 | return undefined; |
| 80 | } |
| 81 | if (phaseRef.current === "closed") return undefined; |
| 82 | phaseRef.current = "closing"; |
| 83 | setPhase("closing"); |
| 84 | const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 85 | id = window.setTimeout(() => { |
| 86 | phaseRef.current = "closed"; |
| 87 | setPhase("closed"); |
| 88 | positionRef.current = null; |
| 89 | setPosition(null); |
| 90 | }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS); |
| 91 | return () => { |
| 92 | if (id !== undefined) window.clearTimeout(id); |
| 93 | }; |
| 94 | }, [open]); |
| 95 | |
| 96 | const rendered = closing || phase !== "closed"; |
| 97 | |
| 98 | useLayoutEffect(() => { |
| 99 | if (!rendered) { |
| 100 | positionRef.current = null; |
| 101 | setPosition(null); |
| 102 | return; |
| 103 | } |
| 104 | let frame: number | null = null; |
| 105 | let initialMeasurementRetries = 0; |
| 106 | const scheduleUpdate = () => { |
| 107 | if (frame !== null) return; |
| 108 | frame = window.requestAnimationFrame(updatePosition); |
| 109 | }; |
| 110 | const updatePosition = () => { |
| 111 | frame = null; |
| 112 | const anchor = anchorRef.current?.getBoundingClientRect(); |
| 113 | const menu = popoverRef.current?.getBoundingClientRect(); |
| 114 | // Keep an existing valid position while either element is temporarily |
| 115 | // unavailable. Before the first measurement, the render fallback below |
| 116 | // keeps the popover visible without pretending layout is ready. Retry a |
| 117 | // bounded number of frames so a late anchor can still become measurable. |
| 118 | if (!anchor || !menu) { |
| 119 | if ( |
| 120 | positionRef.current === null && |
| 121 | initialMeasurementRetries < MAX_INITIAL_POSITION_RETRY_FRAMES |
| 122 | ) { |
| 123 | initialMeasurementRetries += 1; |
| 124 | scheduleUpdate(); |
| 125 | } |
| 126 | return; |
| 127 | } |
| 128 | initialMeasurementRetries = 0; |
| 129 | const next = calculatePosition(anchor, menu, align, offset, placement); |
| 130 | positionRef.current = next; |
| 131 | setPosition((current) => (samePosition(current, next) ? current : next)); |
| 132 | }; |
| 133 | updatePosition(); |
| 134 | scheduleUpdate(); |
| 135 | |
| 136 | const anchor = anchorRef.current; |
| 137 | const menu = popoverRef.current; |
| 138 | let observer: ResizeObserver | null = null; |
| 139 | if (typeof ResizeObserver !== "undefined") { |
| 140 | observer = new ResizeObserver(scheduleUpdate); |
| 141 | if (anchor) observer.observe(anchor); |
| 142 | if (menu) observer.observe(menu); |
| 143 | } |
| 144 | // Portaled popovers use viewport coordinates; scrollable ancestors move the anchor. |
| 145 | window.addEventListener("scroll", scheduleUpdate, true); |
| 146 | window.visualViewport?.addEventListener("scroll", scheduleUpdate); |
| 147 | window.visualViewport?.addEventListener("resize", scheduleUpdate); |
| 148 | |
| 149 | return () => { |
| 150 | if (frame !== null) window.cancelAnimationFrame(frame); |
| 151 | observer?.disconnect(); |
| 152 | window.removeEventListener("scroll", scheduleUpdate, true); |
| 153 | window.visualViewport?.removeEventListener("scroll", scheduleUpdate); |
| 154 | window.visualViewport?.removeEventListener("resize", scheduleUpdate); |
| 155 | }; |
| 156 | }, [rendered, anchorRef, align, offset, placement]); |
| 157 | |
| 158 | useEffect(() => { |
| 159 | if (!open || closing) return; |
| 160 | const closeOnEscape = (event: KeyboardEvent) => { |
| 161 | if (event.key === "Escape") onClose(); |
| 162 | }; |
| 163 | const closeOnOutsideClick = (event: MouseEvent) => { |
| 164 | const target = event.target; |
| 165 | if (!(target instanceof Node)) return; |
| 166 | if (popoverRef.current?.contains(target) || anchorRef.current?.contains(target)) return; |
| 167 | onClose(); |
| 168 | }; |
| 169 | const closeOnViewportChange = () => onClose(); |
| 170 | window.addEventListener("keydown", closeOnEscape); |
| 171 | document.addEventListener("click", closeOnOutsideClick); |
| 172 | window.addEventListener("resize", closeOnViewportChange); |
| 173 | return () => { |
| 174 | window.removeEventListener("keydown", closeOnEscape); |
| 175 | document.removeEventListener("click", closeOnOutsideClick); |
| 176 | window.removeEventListener("resize", closeOnViewportChange); |
| 177 | }; |
| 178 | }, [anchorRef, onClose, open]); |
| 179 | |
| 180 | if (!rendered) return null; |
| 181 | |
| 182 | return createPortal( |
| 183 | <div |
| 184 | ref={popoverRef} |
| 185 | data-anchored-popover="active" |
| 186 | data-ready={position ? "true" : "false"} |
| 187 | data-state={closing || phase === "closing" ? "closing" : "open"} |
| 188 | aria-hidden={closing || phase === "closing" ? true : undefined} |
| 189 | className={`anchored-popover ${className}`} |
| 190 | style={{ |
| 191 | ...style, |
| 192 | left: position?.left ?? EDGE_GAP, |
| 193 | top: position?.top ?? EDGE_GAP, |
| 194 | visibility: "visible", |
| 195 | }} |
| 196 | onMouseDown={(event) => { |
| 197 | event.stopPropagation(); |
| 198 | }} |
| 199 | onClick={(event) => { |
| 200 | event.stopPropagation(); |
| 201 | }} |
| 202 | > |
| 203 | {children} |
| 204 | </div>, |
| 205 | document.body, |
| 206 | ); |
| 207 | } |
| 208 |