popoverPosition.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. export interface PopoverPosition {
  2. top: number;
  3. left: number;
  4. }
  5. interface RectLike {
  6. top: number;
  7. bottom: number;
  8. left: number;
  9. right: number;
  10. }
  11. export interface ComputePopoverPositionOpts {
  12. /** Trigger element's bounding rect (viewport coordinates). */
  13. triggerRect: RectLike;
  14. /** Popover width in CSS pixels. */
  15. popoverWidth: number;
  16. /**
  17. * Estimated popover height in CSS pixels. Used to detect bottom-edge
  18. * overflow so we can flip above the trigger. A conservative over-estimate
  19. * is preferable to an under-estimate — over-estimating just flips slightly
  20. * sooner, under-estimating leaves the popover clipped off the viewport.
  21. */
  22. estimatedHeight: number;
  23. /** Viewport height. Defaults to window.innerHeight. Injectable for tests. */
  24. viewportHeight?: number;
  25. /** Viewport width. Defaults to window.innerWidth. Injectable for tests. */
  26. viewportWidth?: number;
  27. /** Margin to keep between the popover and the viewport edges. */
  28. margin?: number;
  29. /** Gap between the trigger and the popover. */
  30. gap?: number;
  31. /** Horizontal alignment relative to the trigger. Defaults to right-aligned. */
  32. horizontalAlign?: 'right' | 'center';
  33. }
  34. /**
  35. * Compute fixed-positioning coordinates for a popover anchored to a trigger.
  36. *
  37. * Default placement is BELOW the trigger, right-aligned to the trigger. Flips
  38. * to ABOVE the trigger when below would overflow the viewport (#1447 — the
  39. * AMS drying popover on the printer card sits at the bottom of the AMS row
  40. * and was rendering off the bottom of the viewport with the Start button
  41. * unreachable on smaller screens).
  42. *
  43. * Horizontal axis right-aligns to triggerRect.right and clamps to the
  44. * viewport with the configured margin so a trigger near the right edge
  45. * doesn't push the popover off-screen.
  46. */
  47. export function computePopoverPosition(opts: ComputePopoverPositionOpts): PopoverPosition {
  48. // iOS Safari's bottom URL/toolbar overlay is excluded from window.innerHeight
  49. // but included in the layout viewport, so a popover anchored against
  50. // innerHeight gets its footer clipped behind the toolbar (#1669, iPhone 17
  51. // Safari). visualViewport reflects the actually-visible area when the
  52. // toolbar is up; fall back to innerHeight where it isn't available.
  53. const visualHeight =
  54. typeof window !== 'undefined' && window.visualViewport
  55. ? window.visualViewport.height
  56. : typeof window !== 'undefined'
  57. ? window.innerHeight
  58. : 0;
  59. const {
  60. triggerRect,
  61. popoverWidth,
  62. estimatedHeight,
  63. viewportHeight = visualHeight,
  64. viewportWidth = window.innerWidth,
  65. margin = 8,
  66. gap = 4,
  67. horizontalAlign = 'right',
  68. } = opts;
  69. // Vertical: prefer below, flip to above only when below overflows AND
  70. // above would actually fit. If neither fits (a popover taller than the
  71. // viewport), stay below — at least the top of the popover is visible
  72. // and the user can scroll inside it, which is better than flipping to a
  73. // top-clipped position where the action buttons might also be unreachable.
  74. let top = triggerRect.bottom + gap;
  75. const wouldOverflowBottom = top + estimatedHeight > viewportHeight - margin;
  76. if (wouldOverflowBottom) {
  77. const aboveTop = triggerRect.top - gap - estimatedHeight;
  78. if (aboveTop >= margin) {
  79. top = aboveTop;
  80. }
  81. }
  82. // Horizontal: align to trigger; clamp to viewport bounds.
  83. const triggerCenter = triggerRect.left + ((triggerRect.right - triggerRect.left) / 2);
  84. let left = horizontalAlign === 'center'
  85. ? triggerCenter - (popoverWidth / 2)
  86. : triggerRect.right - popoverWidth;
  87. if (left < margin) {
  88. left = margin;
  89. } else if (left + popoverWidth > viewportWidth - margin) {
  90. left = Math.max(margin, viewportWidth - popoverWidth - margin);
  91. }
  92. return { top, left };
  93. }