import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import type { AriaRole, CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject, } from "react"; import { createPortal } from "react-dom"; import { computeOverlayPosition } from "./overlayPlacement"; import type { Placement as OverlayPlacement } from "./overlayPlacement"; export interface AnchorRect { left: number; top: number; right: number; bottom: number; width: number; height: number; } type Anchor = HTMLElement | AnchorRect; /** * Wave-9 I14 adds `right-start`: the panel sits BESIDE the anchor (top edges aligned), not * under it — the owner's "flyout to the RIGHT". It is a real placement rather than a margin * hack because the views rail is a 188px column at the left edge of an iframe, and a panel * absolutely-positioned inside `.cg-views` would be clipped by the rail it escapes; every * other overlay here is already body-level and fixed for the same reason. * * The placement ARITHMETIC moved to overlayPlacement.ts with that change, so the collision * maths is gated rather than eyeballed (verify_overlay.py). */ type Placement = OverlayPlacement; interface OverlayStack { register: (id: string) => () => void; isTop: (id: string) => boolean; } const OverlayStackContext = createContext(null); const FOCUSABLE = [ "[data-overlay-autofocus]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "[href]", '[tabindex]:not([tabindex="-1"])', ].join(","); export function OverlayProvider({ children }: { children: ReactNode }) { const stack = useRef([]); const api = useMemo( () => ({ register: (id) => { stack.current = [...stack.current.filter((item) => item !== id), id]; return () => { stack.current = stack.current.filter((item) => item !== id); }; }, isTop: (id) => stack.current.at(-1) === id, }), [] ); return ( {children} ); } /** * ⚠ NULL IS A REACHABLE VALUE HERE, and it must not be fatal. * * Half the call sites pass `someRef.current`, which is legitimately null on the render * before the ref attaches, and `Anchor` does not include null — so every one of them was * one ordering accident away from `"getBoundingClientRect" in null`, a TypeError thrown * during RENDER, which unmounts the whole tree. That is not a hypothetical: it is what * `_qa_live_rail.py` reproduced on the shipped build when the saved-view menu's anchor * came back null (2026-08-04). * * A missing anchor is a positioning problem, not a reason to lose the application. The * panel degrades to a zero-rect at the viewport origin — visible, dismissible, obviously * wrong — while the root causes stay fixable at their own call sites. */ const NO_RECT: AnchorRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; function anchorRect(anchor: Anchor | null | undefined): AnchorRect { if (!anchor) return NO_RECT; if ("getBoundingClientRect" in anchor) { const rect = anchor.getBoundingClientRect(); return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height, }; } return anchor; } function samePosition(a: CSSProperties, b: CSSProperties): boolean { return ( a.left === b.left && a.top === b.top && a.maxWidth === b.maxWidth && a.maxHeight === b.maxHeight && a.visibility === b.visibility ); } interface OverlayLayerOptions { panelRef: RefObject; onDismiss: () => void; dismissOnOutside?: boolean; initialFocus?: "first" | "none" | string; restoreFocus?: boolean; trapFocus?: boolean; outsideElements?: Array; } /** Shared dismissal/focus contract for anchored menus and fixed drawers. */ // oxlint-disable-next-line react/only-export-components -- shares the private overlay stack context. export function useOverlayLayer({ panelRef, onDismiss, dismissOnOutside = true, initialFocus = "first", restoreFocus = true, trapFocus = false, outsideElements = [], }: OverlayLayerOptions): void { const id = useId(); const stack = useContext(OverlayStackContext); const dismissRef = useRef(onDismiss); const outsideRef = useRef(outsideElements); dismissRef.current = onDismiss; outsideRef.current = outsideElements; useEffect(() => stack?.register(id), [id, stack]); useEffect(() => { const onPointerDown = (event: PointerEvent) => { if (!dismissOnOutside || (stack && !stack.isTop(id))) return; const target = event.target as Node | null; if (!target || panelRef.current?.contains(target)) return; if (outsideRef.current.some((element) => element?.contains(target))) return; if ( document.activeElement instanceof HTMLElement && panelRef.current?.contains(document.activeElement) ) document.activeElement.blur(); dismissRef.current(); }; const onKeyDown = (event: KeyboardEvent) => { if (stack && !stack.isTop(id)) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if ( document.activeElement instanceof HTMLElement && panelRef.current?.contains(document.activeElement) ) document.activeElement.blur(); dismissRef.current(); return; } if (event.key !== "Tab" || !trapFocus) return; const focusable = Array.from( panelRef.current?.querySelectorAll(FOCUSABLE) ?? [] ).filter((element) => element.getClientRects().length > 0); if (!focusable.length) { event.preventDefault(); panelRef.current?.focus(); return; } const first = focusable[0]; const last = focusable.at(-1)!; const active = document.activeElement; if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } }; document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("keydown", onKeyDown, true); return () => { document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("keydown", onKeyDown, true); }; }, [dismissOnOutside, id, panelRef, stack, trapFocus]); useLayoutEffect(() => { const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; const frame = window.requestAnimationFrame(() => { if (initialFocus === "none") return; const selector = initialFocus === "first" ? FOCUSABLE : initialFocus; const target = panelRef.current?.querySelector(selector); target?.focus({ preventScroll: true }); }); return () => { window.cancelAnimationFrame(frame); if (restoreFocus && previous?.isConnected) previous.focus({ preventScroll: true }); }; }, [initialFocus, panelRef, restoreFocus]); } export function BodyPortal({ children }: { children: ReactNode }) { return createPortal(children, document.body); } interface AnchoredOverlayProps { /** ⚠ Nullable BY DECLARATION as of 2026-08-04. Several call sites pass `ref.current`, * which is null before the ref attaches, and the old non-null type made that a * render-time TypeError instead of a type error. See `anchorRect`. */ anchor: Anchor | null | undefined; className: string; children: ReactNode; onDismiss: () => void; placement?: Placement; role?: AriaRole; ariaLabel?: string; id?: string; initialFocus?: "first" | "none" | string; restoreFocus?: boolean; dismissOnOutside?: boolean; onKeyDown?: (event: ReactKeyboardEvent) => void; dataKind?: string; } /** Body-level fixed overlay with iframe-viewport collision handling. */ export function AnchoredOverlay({ anchor, className, children, onDismiss, placement = "bottom-start", role, ariaLabel, id, initialFocus = "first", restoreFocus = true, dismissOnOutside = true, onKeyDown, dataKind, }: AnchoredOverlayProps) { const panelRef = useRef(null); const [style, setStyle] = useState({ position: "fixed", left: 0, top: 0, visibility: "hidden", }); const anchorElement = anchor && "getBoundingClientRect" in anchor ? anchor : null; useOverlayLayer({ panelRef, onDismiss, dismissOnOutside, initialFocus, restoreFocus, outsideElements: [anchorElement], }); const updatePosition = useCallback(() => { const panel = panelRef.current; if (!panel) return; const target = anchorRect(anchor); const viewport = window.visualViewport; const viewportLeft = viewport?.offsetLeft ?? 0; const viewportTop = viewport?.offsetTop ?? 0; const viewportWidth = viewport?.width ?? window.innerWidth; const viewportHeight = viewport?.height ?? window.innerHeight; const measured = panel.getBoundingClientRect(); // The arithmetic lives in overlayPlacement.ts so a gate can run it under node. This // reads the DOM, decides nothing. const placed = computeOverlayPosition({ placement, target, panel: { width: Math.max(measured.width, panel.scrollWidth), height: Math.max(measured.height, panel.scrollHeight), }, viewport: { left: viewportLeft, top: viewportTop, width: viewportWidth, height: viewportHeight, }, }); const next: CSSProperties = { position: "fixed", left: placed.left, top: placed.top, maxWidth: placed.maxWidth, maxHeight: placed.maxHeight, visibility: "visible", }; setStyle((current) => (samePosition(current, next) ? current : next)); }, [anchor, placement]); useLayoutEffect(() => { updatePosition(); const frame = window.requestAnimationFrame(updatePosition); const observer = new ResizeObserver(updatePosition); if (panelRef.current) observer.observe(panelRef.current); if (anchorElement) observer.observe(anchorElement); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); window.visualViewport?.addEventListener("resize", updatePosition); window.visualViewport?.addEventListener("scroll", updatePosition); return () => { window.cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); window.visualViewport?.removeEventListener("resize", updatePosition); window.visualViewport?.removeEventListener("scroll", updatePosition); }; }, [anchorElement, updatePosition]); return (
{children}
); }