Просмотр исходного кода

fix(ui): drying popover footer no longer clipped by iOS Safari URL bar (#1669)

  100vh and window.innerHeight report the layout viewport on iOS Safari, so
  the popover's Start Drying button rendered behind the bottom toolbar overlay.
  Switch the popover maxHeight to 100dvh and default computePopoverPosition's
  viewportHeight to visualViewport.height (with innerHeight fallback). The
  flip-above decision and the body-scroll fallback now both run against the
  actually-visible area, so the footer button stays reachable on iPhone.
maziggy 3 месяцев назад
Родитель
Сommit
ea4d5eb794

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 91 - 1
frontend/src/__tests__/utils/popoverPosition.test.ts

@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, afterEach } from 'vitest';
 import { computePopoverPosition } from '../../utils/popoverPosition';
 import { computePopoverPosition } from '../../utils/popoverPosition';
 
 
 /**
 /**
@@ -114,3 +114,93 @@ describe('computePopoverPosition (#1447)', () => {
     expect(pos.top).toBe(middleTrigger.bottom + 12); // 332
     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);
+  });
+});

+ 4 - 2
frontend/src/pages/PrintersPage.tsx

@@ -5439,8 +5439,10 @@ function PrinterCard({
                 // margin). When the popover is taller than that space — short
                 // margin). When the popover is taller than that space — short
                 // viewport, landscape phone, zoomed-in — the body scrolls and
                 // viewport, landscape phone, zoomed-in — the body scrolls and
                 // the footer stays pinned, so the Start button is always
                 // the footer stays pinned, so the Start button is always
-                // reachable (#1458 / #1447 follow-up).
-                maxHeight: `calc(100vh - ${dryingPopoverPos.top}px - 8px)`,
+                // reachable (#1458 / #1447 follow-up). dvh (not vh) so iOS
+                // Safari's bottom toolbar overlay doesn't clip the footer
+                // (#1669, iPhone 17 Safari).
+                maxHeight: `calc(100dvh - ${dryingPopoverPos.top}px - 8px)`,
               }}
               }}
               onClick={e => e.stopPropagation()}
               onClick={e => e.stopPropagation()}
             >
             >

+ 12 - 1
frontend/src/utils/popoverPosition.ts

@@ -46,11 +46,22 @@ export interface ComputePopoverPositionOpts {
  * doesn't push the popover off-screen.
  * doesn't push the popover off-screen.
  */
  */
 export function computePopoverPosition(opts: ComputePopoverPositionOpts): PopoverPosition {
 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 {
   const {
     triggerRect,
     triggerRect,
     popoverWidth,
     popoverWidth,
     estimatedHeight,
     estimatedHeight,
-    viewportHeight = window.innerHeight,
+    viewportHeight = visualHeight,
     viewportWidth = window.innerWidth,
     viewportWidth = window.innerWidth,
     margin = 8,
     margin = 8,
     gap = 4,
     gap = 4,

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-SS1wDI23.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BCHcur4g.js"></script>
+    <script type="module" crossorigin src="/assets/index-SS1wDI23.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DgecYhis.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DgecYhis.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов