import { describe, it, expect, afterEach } from 'vitest'; import { computePopoverPosition } from '../../utils/popoverPosition'; /** * Tests for #1447: the AMS drying popover was rendering off the bottom of * the viewport with the Start button unreachable. The new helper must: * - keep the popover below when below fits * - flip above when below would overflow AND above fits * - stay below (degraded) when neither side fits * - clamp the horizontal position so a trigger near the viewport's right * edge doesn't push the popover off-screen. */ describe('computePopoverPosition (#1447)', () => { // Trigger positioned in the middle of a 1024x768 viewport. const middleTrigger = { top: 300, bottom: 320, left: 400, right: 440 }; const viewport = { viewportWidth: 1024, viewportHeight: 768 }; it('places the popover below the trigger when below has room', () => { const pos = computePopoverPosition({ triggerRect: middleTrigger, popoverWidth: 240, estimatedHeight: 320, ...viewport, }); expect(pos.top).toBe(middleTrigger.bottom + 4); // 324 }); it('right-aligns the popover to the trigger by default', () => { const pos = computePopoverPosition({ triggerRect: middleTrigger, popoverWidth: 240, estimatedHeight: 320, ...viewport, }); expect(pos.left).toBe(middleTrigger.right - 240); // 200 }); it('can center-align the popover to the trigger', () => { const pos = computePopoverPosition({ triggerRect: middleTrigger, popoverWidth: 240, estimatedHeight: 320, horizontalAlign: 'center', ...viewport, }); expect(pos.left).toBe(300); }); it('flips above when the popover would overflow the bottom of the viewport', () => { // Trigger near the bottom — bottom=700 + gap 4 + height 320 = 1024 > 768. const bottomTrigger = { top: 680, bottom: 700, left: 400, right: 440 }; const pos = computePopoverPosition({ triggerRect: bottomTrigger, popoverWidth: 240, estimatedHeight: 320, ...viewport, }); // Above placement: trigger.top - gap - height = 680 - 4 - 320 = 356. expect(pos.top).toBe(356); }); it('stays below when neither below nor above can fully fit (degraded)', () => { // A popover taller than the viewport itself can never fit anywhere. Stay // below so the user at least sees the top of the popover and can scroll // through it — flipping to a top-clipped position would lose visibility // of the action buttons at the bottom of the popover too. const tallPopover = { estimatedHeight: 900 }; const trigger = { top: 380, bottom: 400, left: 400, right: 440 }; const pos = computePopoverPosition({ triggerRect: trigger, popoverWidth: 240, ...tallPopover, ...viewport, }); expect(pos.top).toBe(trigger.bottom + 4); }); it('clamps horizontally when trigger sits near the right viewport edge', () => { // Trigger.right=1020, popoverWidth=240. Default left would be 780; the // popover would extend to 1020 which is within viewport=1024 minus the // 8px margin -> 1016, so it overflows by 4px. Clamp pushes it left. const rightEdgeTrigger = { top: 100, bottom: 120, left: 980, right: 1020 }; const pos = computePopoverPosition({ triggerRect: rightEdgeTrigger, popoverWidth: 240, estimatedHeight: 320, ...viewport, }); expect(pos.left).toBeLessThanOrEqual(1024 - 240 - 8); // 776 expect(pos.left).toBeGreaterThanOrEqual(8); }); it('clamps horizontally when trigger sits near the left viewport edge', () => { // Trigger.right=120, popoverWidth=240. Default left would be -120 (off // viewport). Clamp to the margin. const leftEdgeTrigger = { top: 100, bottom: 120, left: 80, right: 120 }; const pos = computePopoverPosition({ triggerRect: leftEdgeTrigger, popoverWidth: 240, estimatedHeight: 320, ...viewport, }); expect(pos.left).toBe(8); // default margin }); it('respects a custom margin', () => { const pos = computePopoverPosition({ triggerRect: { top: 100, bottom: 120, left: 80, right: 120 }, popoverWidth: 240, estimatedHeight: 320, margin: 16, ...viewport, }); expect(pos.left).toBe(16); }); it('respects a custom gap between trigger and popover', () => { const pos = computePopoverPosition({ triggerRect: middleTrigger, popoverWidth: 240, estimatedHeight: 320, gap: 12, ...viewport, }); expect(pos.top).toBe(middleTrigger.bottom + 12); // 332 }); }); /** * Tests for #1669: on iPhone Safari the bottom URL bar overlays the layout * viewport, so window.innerHeight reports more vertical space than is * actually visible. The popover's Start button rendered behind the toolbar. * The helper now defaults viewportHeight from visualViewport.height when * present so flip-above triggers against the real visible area. */ describe('computePopoverPosition (#1669, iOS Safari visualViewport)', () => { const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport'); const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight'); afterEach(() => { if (originalVisualViewport) { Object.defineProperty(window, 'visualViewport', originalVisualViewport); } else { // jsdom didn't set it; remove anything we added so other tests see the // pristine state. // @ts-expect-error — deleting an optional property on window delete window.visualViewport; } if (originalInnerHeight) { Object.defineProperty(window, 'innerHeight', originalInnerHeight); } }); it('flips above when visualViewport is shorter than innerHeight (iOS toolbar visible)', () => { // Simulate the iPhone 17 Safari case: layout viewport says 800, but the // bottom URL bar overlay takes 100px so visualViewport reports 700. Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }); Object.defineProperty(window, 'visualViewport', { value: { height: 700 }, configurable: true, }); // Trigger near the visual-viewport bottom: bottom=650 + gap 4 + height // 320 = 974 > 700-8. Without the fix (innerHeight=800), 974 > 800-8 is // also true so it would flip — fine. But subtract: 650+324=974 > 792 (yes) // — so flip happens with either. To prove visualViewport matters we need // a trigger that fits *under innerHeight* but overflows *under // visualViewport*: bottom=400, height=320, total=724. 724 < 800-8 = 792 // (no flip with innerHeight), but 724 > 700-8 = 692 (flip with // visualViewport). const trigger = { top: 380, bottom: 400, left: 400, right: 440 }; const pos = computePopoverPosition({ triggerRect: trigger, popoverWidth: 240, estimatedHeight: 320, // Intentionally NO viewportHeight — exercise the default path. viewportWidth: 1024, }); // Above placement: trigger.top - gap - height = 380 - 4 - 320 = 56. expect(pos.top).toBe(56); }); it('falls back to innerHeight when visualViewport is unavailable', () => { // Some older WebViews / jsdom configurations don't expose visualViewport. // @ts-expect-error — deleting an optional property on window delete window.visualViewport; Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true }); // Trigger near the bottom should still flip above using innerHeight. const trigger = { top: 680, bottom: 700, left: 400, right: 440 }; const pos = computePopoverPosition({ triggerRect: trigger, popoverWidth: 240, estimatedHeight: 320, viewportWidth: 1024, }); expect(pos.top).toBe(680 - 4 - 320); // 356 (trigger.top - gap - height) }); it('respects an explicit viewportHeight even when visualViewport is set', () => { // Tests pass viewportHeight explicitly; that override must still win. Object.defineProperty(window, 'visualViewport', { value: { height: 200 }, configurable: true, }); const pos = computePopoverPosition({ triggerRect: { top: 300, bottom: 320, left: 400, right: 440 }, popoverWidth: 240, estimatedHeight: 320, viewportHeight: 768, viewportWidth: 1024, }); // 320 + 320 = 640 < 768 - 8, so no flip — uses the override, not the 200. expect(pos.top).toBe(324); }); });