diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 80dabe9a5..51e4ae6ce 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -137,7 +137,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ type="button" size="icon" variant="ghost" - className="absolute left-[calc(--spacing(2)+var(--safe-area-left))] top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-[calc(--spacing(6)+var(--safe-area-left))]" + className="absolute left-2 top-1/2 z-20 ml-(--safe-area-left) -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6" aria-label="Previous image" onClick={() => navigateImage(-1)} > @@ -212,7 +212,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ type="button" size="icon" variant="ghost" - className="absolute right-[calc(--spacing(2)+var(--safe-area-right))] top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-[calc(--spacing(6)+var(--safe-area-right))]" + className="absolute right-2 top-1/2 z-20 mr-(--safe-area-right) -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6" aria-label="Next image" onClick={() => navigateImage(1)} > diff --git a/apps/web/src/components/chat/chatFind/ChatFind.tsx b/apps/web/src/components/chat/chatFind/ChatFind.tsx index e5d96b48d..b685c95fc 100644 --- a/apps/web/src/components/chat/chatFind/ChatFind.tsx +++ b/apps/web/src/components/chat/chatFind/ChatFind.tsx @@ -429,7 +429,7 @@ function ChatFindSession(props: ChatFindProps) { return (
); 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( + + + + Connect to a computer + + + + , + ); + + 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); }; }