| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- 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);
- });
- });
|