fix: prevent mobile keyboard from occluding terminal viewport (#1303)

* fix: prevent mobile keyboard from occluding terminal viewport

On mobile, 100dvh does not shrink when the soft keyboard opens, causing
the terminal and other absolute-inset-0 views to extend behind the keyboard.

Add useVisualViewport hook that tracks window.visualViewport.resize/scroll
via RAF. On mobile, use the visual viewport height (actual visible area)
for the root container instead of 100dvh, so content resizes above the
keyboard when it opens.

* fix: skip unchanged visual viewport updates

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Junlang Huang
2026-05-18 18:05:13 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 76eef473aa
commit 48af84222a
2 changed files with 57 additions and 2 deletions
@@ -0,0 +1,52 @@
import React from 'react';
export interface VisualViewportState {
height: number;
keyboardHeight: number;
}
const getInitialHeight = (): number => {
if (typeof window === 'undefined') return 0;
return window.visualViewport?.height ?? window.innerHeight;
};
export const useVisualViewport = (): VisualViewportState => {
const [state, setState] = React.useState<VisualViewportState>(() => ({
height: getInitialHeight(),
keyboardHeight: 0,
}));
const rafIdRef = React.useRef<number | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || !window.visualViewport) return;
const handleChange = () => {
if (rafIdRef.current !== null) return;
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
const vv = window.visualViewport!;
const height = vv.height;
const keyboardHeight = Math.max(0, window.innerHeight - height);
setState((prev) => {
if (prev.height === height && prev.keyboardHeight === keyboardHeight) return prev;
return { height, keyboardHeight };
});
});
};
window.visualViewport.addEventListener('resize', handleChange);
window.visualViewport.addEventListener('scroll', handleChange, { passive: true });
handleChange();
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
window.visualViewport?.removeEventListener('resize', handleChange);
window.visualViewport?.removeEventListener('scroll', handleChange);
};
}, []);
return state;
};