116 lines
3.5 KiB
TypeScript
116 lines
3.5 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
const IOS_FALLBACK_MIN_GAP_PX = 30;
|
|
const IOS_FALLBACK_MIN_FOCUSED_GAP_PX = 16;
|
|
|
|
/** Whether the current device is likely mobile (touch-primary, small viewport). */
|
|
function isMobileDevice(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
const hasTouchScreen =
|
|
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
const isNarrow = window.innerWidth <= 768;
|
|
return hasTouchScreen && isNarrow;
|
|
}
|
|
|
|
/** Cached initial viewport height before any keyboard opened. */
|
|
let _initialViewportHeight: number | null = null;
|
|
|
|
/** Returns the viewport height at page load (before any keyboard opens). */
|
|
function getInitialViewportHeight(): number {
|
|
if (_initialViewportHeight === null) {
|
|
_initialViewportHeight = window.innerHeight;
|
|
}
|
|
return _initialViewportHeight;
|
|
}
|
|
|
|
/**
|
|
* Compute how many CSS pixels the virtual keyboard covers from the bottom
|
|
* of the layout viewport. Returns 0 on desktop or when visualViewport is
|
|
* unavailable.
|
|
*
|
|
* Strategy:
|
|
* - Primary: window.innerHeight - vv.offsetTop - vv.height
|
|
* Works on Chrome Android where window.innerHeight stays at full height.
|
|
* - Fallback: initial viewport height - vv.height - vv.offsetTop
|
|
* Works on iOS Safari where window.innerHeight shrinks with the keyboard.
|
|
*/
|
|
function isKeyboardFocusableElement(el: Element | null): boolean {
|
|
if (!el) return false;
|
|
if (el instanceof HTMLTextAreaElement) return true;
|
|
if (el instanceof HTMLInputElement) {
|
|
const nonTextTypes = new Set(["checkbox", "radio", "button", "submit", "reset", "file", "range", "color", "hidden"]);
|
|
return !nonTextTypes.has(el.type);
|
|
}
|
|
return el instanceof HTMLElement && el.isContentEditable;
|
|
}
|
|
|
|
function getKeyboardOverlap(): number {
|
|
if (typeof window === "undefined" || !window.visualViewport) return 0;
|
|
const vv = window.visualViewport;
|
|
const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
|
if (chromeOverlap > 0) return chromeOverlap;
|
|
|
|
const initialHeight = getInitialViewportHeight();
|
|
const gap = Math.max(0, initialHeight - vv.offsetTop - vv.height);
|
|
|
|
if (gap >= IOS_FALLBACK_MIN_GAP_PX) {
|
|
return gap;
|
|
}
|
|
|
|
if (gap >= IOS_FALLBACK_MIN_FOCUSED_GAP_PX && isKeyboardFocusableElement(document.activeElement)) {
|
|
return gap;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/** Reset cached initial viewport height. Exported for tests only. */
|
|
export function _resetInitialViewportHeight(): void {
|
|
_initialViewportHeight = null;
|
|
}
|
|
|
|
interface UseMobileKeyboardOptions {
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export function useMobileKeyboard(
|
|
{ enabled = true }: UseMobileKeyboardOptions = {},
|
|
): { keyboardOverlap: number; viewportHeight: number | null } {
|
|
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
|
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !isMobileDevice()) {
|
|
setKeyboardOverlap(0);
|
|
setViewportHeight(null);
|
|
return;
|
|
}
|
|
|
|
const vv = window.visualViewport;
|
|
if (!vv) {
|
|
setKeyboardOverlap(0);
|
|
setViewportHeight(null);
|
|
return;
|
|
}
|
|
|
|
const update = () => {
|
|
const overlap = getKeyboardOverlap();
|
|
setKeyboardOverlap(overlap);
|
|
setViewportHeight(overlap > 0 ? vv.height : null);
|
|
};
|
|
|
|
update();
|
|
vv.addEventListener("resize", update);
|
|
vv.addEventListener("scroll", update);
|
|
|
|
return () => {
|
|
vv.removeEventListener("resize", update);
|
|
vv.removeEventListener("scroll", update);
|
|
setKeyboardOverlap(0);
|
|
setViewportHeight(null);
|
|
};
|
|
}, [enabled]);
|
|
|
|
return { keyboardOverlap, viewportHeight };
|
|
}
|