import { type CSSProperties, type RefObject, useLayoutEffect, useState, } from "react"; /** Space between the anchor and the panel. */ const GAP = 4; /** Space kept clear of the viewport edges. */ const MARGIN = 8; interface AnchoredFloatOptions { /** * Preferred width in px, or "anchor" to match the anchor element's own width. * Shrunk when the viewport is narrower either way. */ width: number | "anchor"; /** Height of the panel's non-scrolling chrome (search row, borders…). */ chromeHeight: number; minContentHeight: number; maxContentHeight: number; } /** * Positions a floating panel against an anchor element with `position: fixed`, * so no ancestor's `overflow: hidden` / `overflow: auto` can clip it — cards, * modal bodies and scrollers all cut off an absolutely positioned popup. * * Flips to whichever side of the anchor has the most room, clamps horizontally * to the viewport, and publishes the room left for the scrolling part as the * `--float-max-h` custom property. * * Returns `null` while closed; the first value lands in a layout effect, so the * panel is never painted at the wrong spot. */ export function useAnchoredFloat( anchorRef: RefObject, open: boolean, { width, chromeHeight, minContentHeight, maxContentHeight }: AnchoredFloatOptions, ): CSSProperties | null { const [style, setStyle] = useState(null); useLayoutEffect(() => { if (!open) return; const anchor = anchorRef.current; if (!anchor) return; const place = () => { const r = anchor.getBoundingClientRect(); const vw = globalThis.innerWidth; const vh = globalThis.innerHeight; // Clamp the anchor's edges into the viewport first: an auto-growing // textarea can be taller than the screen, and an off-screen edge would // otherwise put the panel out of reach. const anchorTop = Math.min(Math.max(r.top, MARGIN), vh - MARGIN); const anchorBottom = Math.min(Math.max(r.bottom, MARGIN), vh - MARGIN); const roomAbove = anchorTop - GAP - MARGIN; const roomBelow = vh - anchorBottom - GAP - MARGIN; const placeAbove = roomAbove >= roomBelow; const w = Math.min(width === "anchor" ? r.width : width, vw - MARGIN * 2); const content = Math.max( minContentHeight, Math.min(maxContentHeight, (placeAbove ? roomAbove : roomBelow) - chromeHeight), ); // Anchoring by `bottom` when above means the panel's own height never // enters the computation, so it can't jump as the list loads. const next: CSSProperties & Record = { position: "fixed", width: w, left: Math.min( Math.max(MARGIN, r.left), Math.max(MARGIN, vw - w - MARGIN), ), "--float-max-h": `${content}px`, }; if (placeAbove) { next.bottom = vh - anchorTop + GAP; next.top = "auto"; } else { next.top = anchorBottom + GAP; next.bottom = "auto"; } setStyle(next); }; place(); // `true` catches scrolling in any ancestor scroller, not just the window. globalThis.addEventListener("scroll", place, true); globalThis.addEventListener("resize", place); // The anchor itself moves when an auto-resizing textarea grows. const observer = new ResizeObserver(place); observer.observe(anchor); return () => { globalThis.removeEventListener("scroll", place, true); globalThis.removeEventListener("resize", place); observer.disconnect(); }; }, [open, anchorRef, width, chromeHeight, minContentHeight, maxContentHeight]); // Gated on `open` rather than cleared on close: the stale value is never // painted, since the layout effect re-places the panel before the next paint. return open ? style : null; }