/** * Wait until an element is actually visible in the viewport. * * The highlight overlay scrolls an off-screen target into view before * drawing its spotlight — but `scrollIntoView({ behavior: 'smooth' })` * settles asynchronously, so measuring the rect right after the call * yields a stale (mid-scroll or still off-screen) position. This waits * for the browser to confirm the element is on screen. */ /** Options for {@link waitForVisible}. */ export interface WaitForVisibleOptions { /** * Fraction of the element that must be inside the viewport before it * counts as visible (IntersectionObserver threshold). */ threshold?: number; /** * Safety cap (ms). If the element never reaches the threshold — e.g. * it is larger than the viewport, or hidden — the promise resolves * anyway so the overlay never hangs. Fail soft. */ timeoutMs?: number; } /** * Resolve once `element` is at least `threshold` visible in the * viewport, or once `timeoutMs` elapses — whichever comes first. * * Always resolves (never rejects): a never-intersecting element must * not stall the overlay. The boolean result reports whether the * element became visible (`true`) or the timeout fired (`false`), for * callers that want to log it. */ export function waitForVisible( element: Element, { threshold = 0.5, timeoutMs = 800 }: WaitForVisibleOptions = {}, ): Promise { return new Promise((resolve) => { // No IntersectionObserver (old/test env) — resolve immediately and // let the caller measure as before. if (typeof IntersectionObserver === 'undefined') { resolve(false); return; } let settled = false; const finish = (visible: boolean) => { if (settled) return; settled = true; window.clearTimeout(timer); observer.disconnect(); resolve(visible); }; const timer = window.setTimeout(() => finish(false), timeoutMs); const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting && entry.intersectionRatio >= threshold) { finish(true); return; } } }, { threshold }, ); observer.observe(element); }); }