| 1 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 2 | import type { CSSProperties, ReactNode, RefObject } from "react"; |
| 3 | import { createPortal } from "react-dom"; |
| 4 | import { |
| 5 | elementLayoutSize, |
| 6 | isElementExplicitlyHidden, |
| 7 | MAX_INITIAL_OVERLAY_MEASUREMENT_FRAMES, |
| 8 | validAnchorRect, |
| 9 | } from "../lib/anchoredOverlay"; |
| 10 | |
| 11 | type PopoverPosition = { |
| 12 | left: number; |
| 13 | top: number; |
| 14 | }; |
| 15 | type PopoverPhase = "closed" | "open" | "closing"; |
| 16 | |
| 17 | const EDGE_GAP = 8; |
| 18 | const DEFAULT_OFFSET = 8; |
| 19 | export const ANCHORED_POPOVER_CLOSE_MS = 140; |
| 20 | |
| 21 | function clamp(value: number, min: number, max: number): number { |
| 22 | return Math.min(Math.max(value, min), max); |
| 23 | } |
| 24 | |
| 25 | function samePosition(a: PopoverPosition | null, b: PopoverPosition): boolean { |
| 26 | return !!a && Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5; |
| 27 | } |
| 28 | |
| 29 | function calculatePosition( |
| 30 | anchor: DOMRect, |
| 31 | menu: { width: number; height: number }, |
| 32 | align: "start" | "end", |
| 33 | offset: number, |
| 34 | placement: "auto" | "bottom", |
| 35 | ): PopoverPosition { |
| 36 | const viewportWidth = window.innerWidth; |
| 37 | const viewportHeight = window.innerHeight; |
| 38 | const preferredTop = anchor.top - menu.height - offset; |
| 39 | const fallbackTop = anchor.bottom + offset; |
| 40 | const top = placement === "bottom" |
| 41 | ? Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) |
| 42 | : preferredTop >= EDGE_GAP |
| 43 | ? preferredTop |
| 44 | : Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)); |
| 45 | const rawLeft = align === "end" ? anchor.right - menu.width : anchor.left; |
| 46 | const left = clamp(rawLeft, EDGE_GAP, Math.max(EDGE_GAP, viewportWidth - menu.width - EDGE_GAP)); |
| 47 | return { left, top: clamp(top, EDGE_GAP, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) }; |
| 48 | } |
| 49 | |
| 50 | export function AnchoredPopover({ |
| 51 | open, |
| 52 | anchorRef, |
| 53 | onClose, |
| 54 | className, |
| 55 | children, |
| 56 | align = "start", |
| 57 | offset = DEFAULT_OFFSET, |
| 58 | placement = "auto", |
| 59 | style, |
| 60 | closing = false, |
| 61 | }: { |
| 62 | open: boolean; |
| 63 | anchorRef: RefObject<HTMLElement | null>; |
| 64 | onClose: () => void; |
| 65 | className: string; |
| 66 | children: ReactNode; |
| 67 | align?: "start" | "end"; |
| 68 | offset?: number; |
| 69 | placement?: "auto" | "bottom"; |
| 70 | style?: CSSProperties; |
| 71 | closing?: boolean; |
| 72 | }) { |
| 73 | const [phase, setPhase] = useState<PopoverPhase>(open ? "open" : "closed"); |
| 74 | const [position, setPosition] = useState<PopoverPosition | null>(null); |
| 75 | const popoverRef = useRef<HTMLDivElement>(null); |
| 76 | const phaseRef = useRef<PopoverPhase>(phase); |
| 77 | const positionRef = useRef<PopoverPosition | null>(position); |
| 78 | const onCloseRef = useRef(onClose); |
| 79 | const invalidCloseNotifiedRef = useRef(false); |
| 80 | onCloseRef.current = onClose; |
| 81 | |
| 82 | useLayoutEffect(() => { |
| 83 | let id: number | undefined; |
| 84 | if (open) { |
| 85 | invalidCloseNotifiedRef.current = false; |
| 86 | phaseRef.current = "open"; |
| 87 | setPhase("open"); |
| 88 | return undefined; |
| 89 | } |
| 90 | if (phaseRef.current === "closed") return undefined; |
| 91 | phaseRef.current = "closing"; |
| 92 | setPhase("closing"); |
| 93 | const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 94 | id = window.setTimeout(() => { |
| 95 | phaseRef.current = "closed"; |
| 96 | setPhase("closed"); |
| 97 | positionRef.current = null; |
| 98 | setPosition(null); |
| 99 | }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS); |
| 100 | return () => { |
| 101 | if (id !== undefined) window.clearTimeout(id); |
| 102 | }; |
| 103 | }, [open]); |
| 104 | |
| 105 | const rendered = closing || phase !== "closed"; |
| 106 | |
| 107 | useLayoutEffect(() => { |
| 108 | if (!rendered) { |
| 109 | positionRef.current = null; |
| 110 | setPosition(null); |
| 111 | return; |
| 112 | } |
| 113 | if (!open) return; |
| 114 | let frame: number | null = null; |
| 115 | let initialMeasurementRetries = 0; |
| 116 | const dismissInvalidAnchor = () => { |
| 117 | positionRef.current = null; |
| 118 | setPosition(null); |
| 119 | phaseRef.current = "closed"; |
| 120 | setPhase("closed"); |
| 121 | if (!invalidCloseNotifiedRef.current) { |
| 122 | invalidCloseNotifiedRef.current = true; |
| 123 | onCloseRef.current(); |
| 124 | } |
| 125 | }; |
| 126 | const updatePosition = () => { |
| 127 | frame = null; |
| 128 | const anchorElement = anchorRef.current; |
| 129 | const anchor = validAnchorRect(anchorElement); |
| 130 | const menuElement = popoverRef.current; |
| 131 | const menu = menuElement ? elementLayoutSize(menuElement) : null; |
| 132 | if (!anchor || !menu) { |
| 133 | const explicitlyHidden = !!anchorElement && ( |
| 134 | !anchorElement.isConnected || isElementExplicitlyHidden(anchorElement) |
| 135 | ); |
| 136 | if (positionRef.current === null && !explicitlyHidden && initialMeasurementRetries < MAX_INITIAL_OVERLAY_MEASUREMENT_FRAMES) { |
| 137 | initialMeasurementRetries += 1; |
| 138 | frame = window.requestAnimationFrame(updatePosition); |
| 139 | } else { |
| 140 | dismissInvalidAnchor(); |
| 141 | } |
| 142 | return; |
| 143 | } |
| 144 | initialMeasurementRetries = 0; |
| 145 | const next = calculatePosition(anchor, menu, align, offset, placement); |
| 146 | if (!samePosition(positionRef.current, next)) { |
| 147 | positionRef.current = next; |
| 148 | setPosition(next); |
| 149 | } |
| 150 | frame = window.requestAnimationFrame(updatePosition); |
| 151 | }; |
| 152 | updatePosition(); |
| 153 | |
| 154 | return () => { |
| 155 | if (frame !== null) window.cancelAnimationFrame(frame); |
| 156 | }; |
| 157 | }, [rendered, open, anchorRef, align, offset, placement]); |
| 158 | |
| 159 | useEffect(() => { |
| 160 | if (!open || closing) return; |
| 161 | const closeOnEscape = (event: KeyboardEvent) => { |
| 162 | if (event.key === "Escape") onClose(); |
| 163 | }; |
| 164 | const closeOnOutsideClick = (event: MouseEvent) => { |
| 165 | const target = event.target; |
| 166 | if (!(target instanceof Node)) return; |
| 167 | if (popoverRef.current?.contains(target) || anchorRef.current?.contains(target)) return; |
| 168 | onClose(); |
| 169 | }; |
| 170 | window.addEventListener("keydown", closeOnEscape); |
| 171 | document.addEventListener("click", closeOnOutsideClick); |
| 172 | return () => { |
| 173 | window.removeEventListener("keydown", closeOnEscape); |
| 174 | document.removeEventListener("click", closeOnOutsideClick); |
| 175 | }; |
| 176 | }, [anchorRef, onClose, open]); |
| 177 | |
| 178 | if (!rendered) return null; |
| 179 | |
| 180 | return createPortal( |
| 181 | <div |
| 182 | ref={popoverRef} |
| 183 | data-app-overlay="" |
| 184 | data-anchored-popover="active" |
| 185 | data-ready={position ? "true" : "false"} |
| 186 | data-state={closing || phase === "closing" ? "closing" : "open"} |
| 187 | aria-hidden={closing || phase === "closing" ? true : undefined} |
| 188 | className={`anchored-popover ${className}`} |
| 189 | style={{ |
| 190 | ...style, |
| 191 | left: position?.left ?? 0, |
| 192 | top: position?.top ?? 0, |
| 193 | visibility: position ? "visible" : "hidden", |
| 194 | pointerEvents: position ? undefined : "none", |
| 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 |