export interface PopoverPosition { top: number; left: number; } interface RectLike { top: number; bottom: number; left: number; right: number; } export interface ComputePopoverPositionOpts { /** Trigger element's bounding rect (viewport coordinates). */ triggerRect: RectLike; /** Popover width in CSS pixels. */ popoverWidth: number; /** * Estimated popover height in CSS pixels. Used to detect bottom-edge * overflow so we can flip above the trigger. A conservative over-estimate * is preferable to an under-estimate — over-estimating just flips slightly * sooner, under-estimating leaves the popover clipped off the viewport. */ estimatedHeight: number; /** Viewport height. Defaults to window.innerHeight. Injectable for tests. */ viewportHeight?: number; /** Viewport width. Defaults to window.innerWidth. Injectable for tests. */ viewportWidth?: number; /** Margin to keep between the popover and the viewport edges. */ margin?: number; /** Gap between the trigger and the popover. */ gap?: number; /** Horizontal alignment relative to the trigger. Defaults to right-aligned. */ horizontalAlign?: 'right' | 'center'; } /** * Compute fixed-positioning coordinates for a popover anchored to a trigger. * * Default placement is BELOW the trigger, right-aligned to the trigger. Flips * to ABOVE the trigger when below would overflow the viewport (#1447 — the * AMS drying popover on the printer card sits at the bottom of the AMS row * and was rendering off the bottom of the viewport with the Start button * unreachable on smaller screens). * * Horizontal axis right-aligns to triggerRect.right and clamps to the * viewport with the configured margin so a trigger near the right edge * doesn't push the popover off-screen. */ export function computePopoverPosition(opts: ComputePopoverPositionOpts): PopoverPosition { // iOS Safari's bottom URL/toolbar overlay is excluded from window.innerHeight // but included in the layout viewport, so a popover anchored against // innerHeight gets its footer clipped behind the toolbar (#1669, iPhone 17 // Safari). visualViewport reflects the actually-visible area when the // toolbar is up; fall back to innerHeight where it isn't available. const visualHeight = typeof window !== 'undefined' && window.visualViewport ? window.visualViewport.height : typeof window !== 'undefined' ? window.innerHeight : 0; const { triggerRect, popoverWidth, estimatedHeight, viewportHeight = visualHeight, viewportWidth = window.innerWidth, margin = 8, gap = 4, horizontalAlign = 'right', } = opts; // Vertical: prefer below, flip to above only when below overflows AND // above would actually fit. If neither fits (a popover taller than the // viewport), stay below — at least the top of the popover is visible // and the user can scroll inside it, which is better than flipping to a // top-clipped position where the action buttons might also be unreachable. let top = triggerRect.bottom + gap; const wouldOverflowBottom = top + estimatedHeight > viewportHeight - margin; if (wouldOverflowBottom) { const aboveTop = triggerRect.top - gap - estimatedHeight; if (aboveTop >= margin) { top = aboveTop; } } // Horizontal: align to trigger; clamp to viewport bounds. const triggerCenter = triggerRect.left + ((triggerRect.right - triggerRect.left) / 2); let left = horizontalAlign === 'center' ? triggerCenter - (popoverWidth / 2) : triggerRect.right - popoverWidth; if (left < margin) { left = margin; } else if (left + popoverWidth > viewportWidth - margin) { left = Math.max(margin, viewportWidth - popoverWidth - margin); } return { top, left }; }