64 lines
2.1 KiB
TypeScript
64 lines
2.1 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Tracks the *visual* viewport — the part of the page not covered by the
|
||
* software keyboard.
|
||
*
|
||
* Why this exists: on iOS Safari `100dvh` does not shrink when the keyboard
|
||
* opens. The dynamic viewport units respond to browser chrome collapsing, not
|
||
* to the keyboard, so a composer pinned to the bottom of a `100dvh` column
|
||
* ends up underneath it. `window.visualViewport` is the only thing iOS updates
|
||
* reliably, so the thread is sized from that instead while the keyboard is up.
|
||
*
|
||
* `offsetTop` matters as much as `height`: iOS scrolls the layout viewport to
|
||
* reveal the focused field rather than resizing it, so a panel pinned to the
|
||
* visible area has to follow that offset or it drifts off-screen.
|
||
*/
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
export type VisualViewport = {
|
||
/** Visible height in px, excluding the keyboard. */
|
||
height: number;
|
||
/** How far the layout viewport has scrolled to reveal the focused field. */
|
||
offsetTop: number;
|
||
/** True once the visible area is materially shorter than the window. */
|
||
keyboardOpen: boolean;
|
||
};
|
||
|
||
/**
|
||
* A URL bar collapsing costs ~50–60px; a keyboard costs several hundred. The
|
||
* threshold keeps ordinary chrome changes from being read as a keyboard.
|
||
*/
|
||
const KEYBOARD_THRESHOLD_PX = 120;
|
||
|
||
export function useVisualViewport(): VisualViewport | null {
|
||
const [state, setState] = useState<VisualViewport | null>(null);
|
||
|
||
useEffect(() => {
|
||
const vv = window.visualViewport;
|
||
// Absent on older browsers — callers fall back to their CSS height.
|
||
if (!vv) return;
|
||
|
||
const apply = () => {
|
||
setState({
|
||
height: vv.height,
|
||
offsetTop: vv.offsetTop,
|
||
keyboardOpen: window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX,
|
||
});
|
||
};
|
||
|
||
apply();
|
||
vv.addEventListener("resize", apply);
|
||
// iOS fires scroll (not resize) as the layout viewport shifts under the
|
||
// keyboard, so both are needed to stay pinned.
|
||
vv.addEventListener("scroll", apply);
|
||
return () => {
|
||
vv.removeEventListener("resize", apply);
|
||
vv.removeEventListener("scroll", apply);
|
||
};
|
||
}, []);
|
||
|
||
return state;
|
||
}
|