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]);