);
diff --git a/apps/web/src/components/ui/dialog.browser.tsx b/apps/web/src/components/ui/dialog.browser.tsx
index 52505f872..d95af58bb 100644
--- a/apps/web/src/components/ui/dialog.browser.tsx
+++ b/apps/web/src/components/ui/dialog.browser.tsx
@@ -62,6 +62,37 @@ describe("Dialog", () => {
}
});
+ it("keeps a phone's bottom sheet whole while the page reads as off screen for a moment", async () => {
+ await page.viewport(PHONE_VIEWPORT.width, PHONE_VIEWPORT.height);
+ const mounted = await render(
+ ,
+ );
+
+ try {
+ await vi.waitFor(() => {
+ expect(popupRect()?.bottom).toBeCloseTo(window.innerHeight, 0);
+ });
+ const height = popupRect()!.height;
+
+ // A folding phone moving the page between its screens: none of it
+ // shows, and read as a keyboard that squeezed the sheet to nothing.
+ showOnScreenKeyboard({ height: window.visualViewport!.height, pageScrolledUpBy: 0 });
+ await nextFrames(5);
+
+ expect(popupRect()?.height).toBeCloseTo(height, 0);
+ expect(popupRect()?.bottom).toBeCloseTo(window.innerHeight, 0);
+ } finally {
+ await mounted.unmount();
+ }
+ });
+
it("keeps a tall sheet inside what's visible when Safari scrolls the page up for the keyboard", async () => {
await page.viewport(PHONE_VIEWPORT.width, PHONE_VIEWPORT.height);
const mounted = await render(
diff --git a/apps/web/src/hooks/useOnScreenKeyboardInset.ts b/apps/web/src/hooks/useOnScreenKeyboardInset.ts
index ec46ce2c8..13f5bfa8d 100644
--- a/apps/web/src/hooks/useOnScreenKeyboardInset.ts
+++ b/apps/web/src/hooks/useOnScreenKeyboardInset.ts
@@ -1,6 +1,10 @@
import { type CSSProperties, useCallback, useSyncExternalStore } from "react";
-import { readVisualViewportInset, type VisualViewportEdge } from "~/lib/onScreenKeyboard";
+import {
+ readVisualViewportInset,
+ type VisualViewportEdge,
+ watchOnScreenKeyboard,
+} from "~/lib/onScreenKeyboard";
function getServerSnapshot(): number {
return 0;
@@ -9,15 +13,8 @@ function getServerSnapshot(): number {
function useVisualViewportInset(edge: VisualViewportEdge, enabled: boolean): number {
const subscribe = useCallback(
(callback: () => void) => {
- if (!enabled || typeof window === "undefined") return () => {};
- const viewport = window.visualViewport;
- if (!viewport) return () => {};
- viewport.addEventListener("resize", callback);
- viewport.addEventListener("scroll", callback);
- return () => {
- viewport.removeEventListener("resize", callback);
- viewport.removeEventListener("scroll", callback);
- };
+ if (!enabled) return () => {};
+ return watchOnScreenKeyboard(callback);
},
[enabled],
);
diff --git a/apps/web/src/lib/onScreenKeyboard.browser.tsx b/apps/web/src/lib/onScreenKeyboard.browser.tsx
index e2b0f8766..f699d81ea 100644
--- a/apps/web/src/lib/onScreenKeyboard.browser.tsx
+++ b/apps/web/src/lib/onScreenKeyboard.browser.tsx
@@ -24,4 +24,25 @@ describe("installOnScreenKeyboardFlag", () => {
window.visualViewport?.dispatchEvent(new Event("resize"));
expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
});
+
+ it("does not take a page that is off screen for a moment for a keyboard", () => {
+ const root = document.documentElement;
+ uninstall = installOnScreenKeyboardFlag();
+
+ // A folding phone moving the page from one of its screens to the other:
+ // for a moment none of the page shows.
+ showOnScreenKeyboard({ height: window.visualViewport!.height, pageScrolledUpBy: 0 });
+ expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
+ });
+
+ it("looks again when the window resizes with no word from the visual viewport", () => {
+ const root = document.documentElement;
+ uninstall = installOnScreenKeyboardFlag();
+ const viewport = window.visualViewport!;
+
+ vi.spyOn(viewport, "height", "get").mockReturnValue(viewport.height - 260);
+ expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
+ window.dispatchEvent(new Event("resize"));
+ expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(true);
+ });
});
diff --git a/apps/web/src/lib/onScreenKeyboard.ts b/apps/web/src/lib/onScreenKeyboard.ts
index af3564bd6..f7c0980dc 100644
--- a/apps/web/src/lib/onScreenKeyboard.ts
+++ b/apps/web/src/lib/onScreenKeyboard.ts
@@ -1,25 +1,66 @@
export type VisualViewportEdge = "top" | "bottom";
-/** Pinch zoom also shrinks the visual viewport; only a 1:1 scale reliably means
- * the height lost is the on-screen keyboard's. */
-function unzoomedVisualViewport(): VisualViewport | null {
+/**
+ * The least of the window a visual viewport can show and still be read as a
+ * page with a keyboard over it. No on-screen keyboard takes nine tenths of
+ * its window (a small phone on its side keeps about a third). A reading
+ * under this is taken to mean the page is off screen for a moment. That is
+ * the best explanation, not a measured one, for an iPhone Duo's sheets and
+ * drawers opening squeezed to nothing after a fold: read as a keyboard, a
+ * visual viewport of about no height pads them by the whole window, and
+ * the reading can outlast the fold with no resize event to correct it.
+ */
+const VISIBLE_PAGE_MIN_SHARE_OF_WINDOW = 0.1;
+
+/**
+ * The visual viewport, when what it has lost can be an on-screen keyboard's
+ * doing. Pinch zoom shrinks it too, so only a 1:1 scale counts, and so does
+ * only a viewport that still shows some of the page.
+ */
+function keyboardVisualViewport(): VisualViewport | null {
const viewport = typeof window === "undefined" ? null : window.visualViewport;
- if (!viewport || Math.abs(viewport.scale - 1) > 0.01) {
+ if (
+ !viewport ||
+ Math.abs(viewport.scale - 1) > 0.01 ||
+ viewport.height < window.innerHeight * VISIBLE_PAGE_MIN_SHARE_OF_WINDOW
+ ) {
return null;
}
return viewport;
}
+/**
+ * Calls back when an on-screen keyboard may have come, gone or moved the
+ * page. The window's own resize counts too: the readings compare the visual
+ * viewport with the window, and the window can change size (a folding phone,
+ * a turned one) with no event on the visual viewport. Returns the way to stop.
+ */
+export function watchOnScreenKeyboard(onChange: () => void): () => void {
+ const viewport = typeof window === "undefined" ? undefined : window.visualViewport;
+ if (!viewport) {
+ return () => {};
+ }
+ viewport.addEventListener("resize", onChange);
+ viewport.addEventListener("scroll", onChange);
+ window.addEventListener("resize", onChange);
+ return () => {
+ viewport.removeEventListener("resize", onChange);
+ viewport.removeEventListener("scroll", onChange);
+ window.removeEventListener("resize", onChange);
+ };
+}
+
/**
* How much of the layout viewport an on-screen keyboard leaves out of view at
* one edge, in px: at the bottom, what the keyboard covers; at the top, how far
* iOS Safari scrolled the page up to show the focused field above it. Only
* browsers whose keyboard slides over the page (iOS Safari) report anything;
* one that resizes the page instead (Android Chrome, which honors
- * `interactive-widget=resizes-content`) reports 0, as does a pinch-zoomed page.
+ * `interactive-widget=resizes-content`) reports 0, as does a pinch-zoomed page
+ * and one that is off screen for a moment.
*/
export function readVisualViewportInset(edge: VisualViewportEdge): number {
- const viewport = unzoomedVisualViewport();
+ const viewport = keyboardVisualViewport();
if (!viewport) {
return 0;
}
@@ -31,7 +72,7 @@ export function readVisualViewportInset(edge: VisualViewportEdge): number {
/** The visual viewport's whole shrink, whichever edge Safari scrolled it from. */
function readOnScreenKeyboardHeight(): number {
- const viewport = unzoomedVisualViewport();
+ const viewport = keyboardVisualViewport();
return viewport ? Math.max(0, Math.round(window.innerHeight - viewport.height)) : 0;
}
@@ -60,8 +101,7 @@ export const ON_SCREEN_KEYBOARD_ATTRIBUTE = "data-on-screen-keyboard";
* Returns the way to undo it.
*/
export function installOnScreenKeyboardFlag(): () => void {
- const viewport = typeof window === "undefined" ? undefined : window.visualViewport;
- if (!viewport) {
+ if (typeof window === "undefined" || !window.visualViewport) {
return () => {};
}
const root = document.documentElement;
@@ -72,9 +112,9 @@ export function installOnScreenKeyboardFlag(): () => void {
);
};
sync();
- viewport.addEventListener("resize", sync);
+ const stopWatching = watchOnScreenKeyboard(sync);
return () => {
- viewport.removeEventListener("resize", sync);
+ stopWatching();
root.removeAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE);
};
}