mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { computePopoverPosition } from '../../utils/popoverPosition';
|
||||
|
||||
/**
|
||||
@@ -114,3 +114,93 @@ describe('computePopoverPosition (#1447)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user