diff --git a/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx b/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx index 2ddb6fb32..7d7fa9730 100644 --- a/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx +++ b/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx @@ -74,20 +74,22 @@ describe("useMobileVisualViewportHeight", () => { await withFakeVisualViewport(visualViewport, async () => { const { rerender } = render(); const shell = screen.getByTestId("shell"); - expect(shell.style.height).toBe("520px"); + expect(shell.style.top).toBe("20px"); + expect(shell.style.height).toBe("500px"); act(() => { visualViewport.height = 300; visualViewport.dispatchEvent(new Event("resize")); }); - await waitFor(() => expect(shell.style.height).toBe("320px")); + await waitFor(() => expect(shell.style.height).toBe("300px")); rerender(); + expect(shell.style.top).toBe(""); expect(shell.style.height).toBe(""); }); }); - it("undoes Safari's focus-reveal pan when the page is not pinch-zoomed", async () => { + it("compensates when Safari leaves the visual viewport panned", async () => { const visualViewport = new FakeVisualViewport(); visualViewport.offsetTop = 0; await withFakeVisualViewport(visualViewport, async () => { @@ -99,6 +101,8 @@ describe("useMobileVisualViewportHeight", () => { visualViewport.dispatchEvent(new Event("scroll")); }); await waitFor(() => expect(window.scrollTo).toHaveBeenCalledWith(0, 0)); + expect(screen.getByTestId("shell").style.top).toBe("340px"); + expect(screen.getByTestId("shell").style.height).toBe("500px"); }); }); @@ -132,6 +136,7 @@ describe("useMobileVisualViewportHeight", () => { act(() => { editor.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); }); + expect(shell.style.top).toBe(""); expect(shell.style.height).toBe(""); }); }); @@ -149,6 +154,7 @@ describe("useMobileVisualViewportHeight", () => { visualViewport.dispatchEvent(new Event("scroll")); }); await waitFor(() => expect(shell.style.height).toBe("840px")); + expect(shell.style.top).toBe(""); expect(window.scrollTo).not.toHaveBeenCalled(); }); }); diff --git a/apps/app/src/components/layout/useMobileVisualViewportHeight.ts b/apps/app/src/components/layout/useMobileVisualViewportHeight.ts index 6b50b9c17..9445a9090 100644 --- a/apps/app/src/components/layout/useMobileVisualViewportHeight.ts +++ b/apps/app/src/components/layout/useMobileVisualViewportHeight.ts @@ -6,6 +6,10 @@ function getVisualViewportBottom(visualViewport: VisualViewport) { return Math.round(visualViewport.offsetTop + visualViewport.height); } +function getVisualViewportPageTop(visualViewport: VisualViewport) { + return Math.round(window.scrollY + visualViewport.offsetTop); +} + function isKeyboardFocusTarget(target: EventTarget | null): boolean { return ( target instanceof HTMLElement && @@ -23,12 +27,11 @@ function isKeyboardFocusTarget(target: EventTarget | null): boolean { * not end up behind the keyboard. iOS can pan the visual viewport while the * keyboard opens, so both its height and its layout-relative offset matter. * - * Safari also reveals a newly focused editor by panning the page, computed - * against the shell's pre-shrink geometry. Once the shell fits the visible - * viewport that pan is obsolete — it pins the composer to the top of the - * screen mid-animation and leaves the header hidden after the keyboard - * settles — so undo it whenever the page is not pinch-zoomed. Pinch-zoom - * pans (scale > 1) are intentional and must survive. + * Safari also reveals a newly focused editor by panning the page. Compensate + * for that pan directly when the page is not pinch-zoomed. `scrollTo()` does + * not always reset a visual-viewport-only pan in an iOS standalone PWA. The + * shell must move to the visual viewport's page-relative top and use its + * visible height. Pinch-zoom pans (scale > 1) are intentional and must survive. */ export function useMobileVisualViewportHeight( shellRef: RefObject, @@ -42,12 +45,18 @@ export function useMobileVisualViewportHeight( let animationFrame: number | null = null; const updateHeight = () => { animationFrame = null; - if ( - visualViewport.scale === 1 && - (visualViewport.offsetTop > 0 || window.scrollY > 0) - ) { - window.scrollTo(0, 0); + if (visualViewport.scale === 1) { + if (visualViewport.offsetTop > 0 || window.scrollY > 0) { + // Reset a regular layout-viewport scroll when possible. The `top` + // compensation below also handles an iOS visual-viewport-only pan. + window.scrollTo(0, 0); + } + shell.style.top = `${getVisualViewportPageTop(visualViewport)}px`; + shell.style.height = `${Math.round(visualViewport.height)}px`; + return; } + + shell.style.removeProperty("top"); shell.style.height = `${getVisualViewportBottom(visualViewport)}px`; }; const scheduleUpdate = () => { @@ -70,6 +79,7 @@ export function useMobileVisualViewportHeight( window.cancelAnimationFrame(animationFrame); animationFrame = null; } + shell.style.removeProperty("top"); shell.style.removeProperty("height"); }; const handleFocusIn = (event: FocusEvent) => { @@ -93,6 +103,7 @@ export function useMobileVisualViewportHeight( if (animationFrame !== null) { window.cancelAnimationFrame(animationFrame); } + shell.style.removeProperty("top"); shell.style.removeProperty("height"); }; }, [enabled, shellRef]);