86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
/**
|
|
* 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);
|
|
};
|
|
}
|