/** * iOS Safari scrolls `window` to keep a focused input in view. `html, body * { overflow: hidden }` then blocks the user from dragging back, and reload * restores that offset. Reset window scroll only after the keyboard is gone. */ export const KEYBOARD_OPEN_GAP_PX = 100; export const KEYBOARD_CLOSED_SLACK_PX = 1; export function isKeyboardOpen(visualHeight: number, innerHeight: number): boolean { return visualHeight < innerHeight - KEYBOARD_OPEN_GAP_PX; } export function shouldResetWindowScroll(input: { scrollY: number; innerHeight: number; visualViewportHeight: number | null; }): boolean { if (input.scrollY <= 0) return false; if (input.visualViewportHeight == null) return true; if (isKeyboardOpen(input.visualViewportHeight, input.innerHeight)) return false; return input.visualViewportHeight >= input.innerHeight - KEYBOARD_CLOSED_SLACK_PX; } type ViewportLike = { height: number; addEventListener: (type: "resize" | "scroll", listener: () => void) => void; removeEventListener: (type: "resize" | "scroll", listener: () => void) => void; }; export type ViewportScrollLockHost = { matchMedia?: (query: string) => { matches: boolean }; visualViewport?: ViewportLike | null; innerHeight: number; scrollY: number; scrollTo: (x: number, y: number) => void; history: { scrollRestoration: string }; addEventListener: (type: string, listener: () => void) => void; removeEventListener: (type: string, listener: () => void) => void; document: { addEventListener: (type: string, listener: () => void) => void; removeEventListener: (type: string, listener: () => void) => void; }; setTimeout?: (fn: () => void, ms: number) => unknown; }; export function installViewportScrollLock(host: ViewportScrollLockHost): () => void { const coarse = host.matchMedia?.("(pointer: coarse)").matches === true; const viewport = host.visualViewport ?? null; if (!coarse && !viewport) return () => undefined; host.history.scrollRestoration = "manual"; host.scrollTo(0, 0); const resetIfSettled = () => { if (!shouldResetWindowScroll({ scrollY: host.scrollY, innerHeight: host.innerHeight, visualViewportHeight: viewport?.height ?? null, })) return; host.scrollTo(0, 0); }; const onPageShow = () => { host.scrollTo(0, 0); }; const onFocusOut = () => { const later = host.setTimeout ?? ((fn: () => void) => fn()); later(resetIfSettled, 300); }; viewport?.addEventListener("resize", resetIfSettled); viewport?.addEventListener("scroll", resetIfSettled); host.addEventListener("pageshow", onPageShow); host.addEventListener("orientationchange", resetIfSettled); host.document.addEventListener("focusout", onFocusOut); return () => { viewport?.removeEventListener("resize", resetIfSettled); viewport?.removeEventListener("scroll", resetIfSettled); host.removeEventListener("pageshow", onPageShow); host.removeEventListener("orientationchange", resetIfSettled); host.document.removeEventListener("focusout", onFocusOut); }; }