popoverPosition.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import { describe, it, expect, afterEach } from 'vitest';
  2. import { computePopoverPosition } from '../../utils/popoverPosition';
  3. /**
  4. * Tests for #1447: the AMS drying popover was rendering off the bottom of
  5. * the viewport with the Start button unreachable. The new helper must:
  6. * - keep the popover below when below fits
  7. * - flip above when below would overflow AND above fits
  8. * - stay below (degraded) when neither side fits
  9. * - clamp the horizontal position so a trigger near the viewport's right
  10. * edge doesn't push the popover off-screen.
  11. */
  12. describe('computePopoverPosition (#1447)', () => {
  13. // Trigger positioned in the middle of a 1024x768 viewport.
  14. const middleTrigger = { top: 300, bottom: 320, left: 400, right: 440 };
  15. const viewport = { viewportWidth: 1024, viewportHeight: 768 };
  16. it('places the popover below the trigger when below has room', () => {
  17. const pos = computePopoverPosition({
  18. triggerRect: middleTrigger,
  19. popoverWidth: 240,
  20. estimatedHeight: 320,
  21. ...viewport,
  22. });
  23. expect(pos.top).toBe(middleTrigger.bottom + 4); // 324
  24. });
  25. it('right-aligns the popover to the trigger by default', () => {
  26. const pos = computePopoverPosition({
  27. triggerRect: middleTrigger,
  28. popoverWidth: 240,
  29. estimatedHeight: 320,
  30. ...viewport,
  31. });
  32. expect(pos.left).toBe(middleTrigger.right - 240); // 200
  33. });
  34. it('can center-align the popover to the trigger', () => {
  35. const pos = computePopoverPosition({
  36. triggerRect: middleTrigger,
  37. popoverWidth: 240,
  38. estimatedHeight: 320,
  39. horizontalAlign: 'center',
  40. ...viewport,
  41. });
  42. expect(pos.left).toBe(300);
  43. });
  44. it('flips above when the popover would overflow the bottom of the viewport', () => {
  45. // Trigger near the bottom — bottom=700 + gap 4 + height 320 = 1024 > 768.
  46. const bottomTrigger = { top: 680, bottom: 700, left: 400, right: 440 };
  47. const pos = computePopoverPosition({
  48. triggerRect: bottomTrigger,
  49. popoverWidth: 240,
  50. estimatedHeight: 320,
  51. ...viewport,
  52. });
  53. // Above placement: trigger.top - gap - height = 680 - 4 - 320 = 356.
  54. expect(pos.top).toBe(356);
  55. });
  56. it('stays below when neither below nor above can fully fit (degraded)', () => {
  57. // A popover taller than the viewport itself can never fit anywhere. Stay
  58. // below so the user at least sees the top of the popover and can scroll
  59. // through it — flipping to a top-clipped position would lose visibility
  60. // of the action buttons at the bottom of the popover too.
  61. const tallPopover = { estimatedHeight: 900 };
  62. const trigger = { top: 380, bottom: 400, left: 400, right: 440 };
  63. const pos = computePopoverPosition({
  64. triggerRect: trigger,
  65. popoverWidth: 240,
  66. ...tallPopover,
  67. ...viewport,
  68. });
  69. expect(pos.top).toBe(trigger.bottom + 4);
  70. });
  71. it('clamps horizontally when trigger sits near the right viewport edge', () => {
  72. // Trigger.right=1020, popoverWidth=240. Default left would be 780; the
  73. // popover would extend to 1020 which is within viewport=1024 minus the
  74. // 8px margin -> 1016, so it overflows by 4px. Clamp pushes it left.
  75. const rightEdgeTrigger = { top: 100, bottom: 120, left: 980, right: 1020 };
  76. const pos = computePopoverPosition({
  77. triggerRect: rightEdgeTrigger,
  78. popoverWidth: 240,
  79. estimatedHeight: 320,
  80. ...viewport,
  81. });
  82. expect(pos.left).toBeLessThanOrEqual(1024 - 240 - 8); // 776
  83. expect(pos.left).toBeGreaterThanOrEqual(8);
  84. });
  85. it('clamps horizontally when trigger sits near the left viewport edge', () => {
  86. // Trigger.right=120, popoverWidth=240. Default left would be -120 (off
  87. // viewport). Clamp to the margin.
  88. const leftEdgeTrigger = { top: 100, bottom: 120, left: 80, right: 120 };
  89. const pos = computePopoverPosition({
  90. triggerRect: leftEdgeTrigger,
  91. popoverWidth: 240,
  92. estimatedHeight: 320,
  93. ...viewport,
  94. });
  95. expect(pos.left).toBe(8); // default margin
  96. });
  97. it('respects a custom margin', () => {
  98. const pos = computePopoverPosition({
  99. triggerRect: { top: 100, bottom: 120, left: 80, right: 120 },
  100. popoverWidth: 240,
  101. estimatedHeight: 320,
  102. margin: 16,
  103. ...viewport,
  104. });
  105. expect(pos.left).toBe(16);
  106. });
  107. it('respects a custom gap between trigger and popover', () => {
  108. const pos = computePopoverPosition({
  109. triggerRect: middleTrigger,
  110. popoverWidth: 240,
  111. estimatedHeight: 320,
  112. gap: 12,
  113. ...viewport,
  114. });
  115. expect(pos.top).toBe(middleTrigger.bottom + 12); // 332
  116. });
  117. });
  118. /**
  119. * Tests for #1669: on iPhone Safari the bottom URL bar overlays the layout
  120. * viewport, so window.innerHeight reports more vertical space than is
  121. * actually visible. The popover's Start button rendered behind the toolbar.
  122. * The helper now defaults viewportHeight from visualViewport.height when
  123. * present so flip-above triggers against the real visible area.
  124. */
  125. describe('computePopoverPosition (#1669, iOS Safari visualViewport)', () => {
  126. const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport');
  127. const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight');
  128. afterEach(() => {
  129. if (originalVisualViewport) {
  130. Object.defineProperty(window, 'visualViewport', originalVisualViewport);
  131. } else {
  132. // jsdom didn't set it; remove anything we added so other tests see the
  133. // pristine state.
  134. // @ts-expect-error — deleting an optional property on window
  135. delete window.visualViewport;
  136. }
  137. if (originalInnerHeight) {
  138. Object.defineProperty(window, 'innerHeight', originalInnerHeight);
  139. }
  140. });
  141. it('flips above when visualViewport is shorter than innerHeight (iOS toolbar visible)', () => {
  142. // Simulate the iPhone 17 Safari case: layout viewport says 800, but the
  143. // bottom URL bar overlay takes 100px so visualViewport reports 700.
  144. Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
  145. Object.defineProperty(window, 'visualViewport', {
  146. value: { height: 700 },
  147. configurable: true,
  148. });
  149. // Trigger near the visual-viewport bottom: bottom=650 + gap 4 + height
  150. // 320 = 974 > 700-8. Without the fix (innerHeight=800), 974 > 800-8 is
  151. // also true so it would flip — fine. But subtract: 650+324=974 > 792 (yes)
  152. // — so flip happens with either. To prove visualViewport matters we need
  153. // a trigger that fits *under innerHeight* but overflows *under
  154. // visualViewport*: bottom=400, height=320, total=724. 724 < 800-8 = 792
  155. // (no flip with innerHeight), but 724 > 700-8 = 692 (flip with
  156. // visualViewport).
  157. const trigger = { top: 380, bottom: 400, left: 400, right: 440 };
  158. const pos = computePopoverPosition({
  159. triggerRect: trigger,
  160. popoverWidth: 240,
  161. estimatedHeight: 320,
  162. // Intentionally NO viewportHeight — exercise the default path.
  163. viewportWidth: 1024,
  164. });
  165. // Above placement: trigger.top - gap - height = 380 - 4 - 320 = 56.
  166. expect(pos.top).toBe(56);
  167. });
  168. it('falls back to innerHeight when visualViewport is unavailable', () => {
  169. // Some older WebViews / jsdom configurations don't expose visualViewport.
  170. // @ts-expect-error — deleting an optional property on window
  171. delete window.visualViewport;
  172. Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true });
  173. // Trigger near the bottom should still flip above using innerHeight.
  174. const trigger = { top: 680, bottom: 700, left: 400, right: 440 };
  175. const pos = computePopoverPosition({
  176. triggerRect: trigger,
  177. popoverWidth: 240,
  178. estimatedHeight: 320,
  179. viewportWidth: 1024,
  180. });
  181. expect(pos.top).toBe(680 - 4 - 320); // 356 (trigger.top - gap - height)
  182. });
  183. it('respects an explicit viewportHeight even when visualViewport is set', () => {
  184. // Tests pass viewportHeight explicitly; that override must still win.
  185. Object.defineProperty(window, 'visualViewport', {
  186. value: { height: 200 },
  187. configurable: true,
  188. });
  189. const pos = computePopoverPosition({
  190. triggerRect: { top: 300, bottom: 320, left: 400, right: 440 },
  191. popoverWidth: 240,
  192. estimatedHeight: 320,
  193. viewportHeight: 768,
  194. viewportWidth: 1024,
  195. });
  196. // 320 + 320 = 640 < 768 - 8, so no flip — uses the override, not the 200.
  197. expect(pos.top).toBe(324);
  198. });
  199. });