Files
fusion/packages/dashboard/app/hooks/useMobileKeyboard.ts
2026-04-30 08:36:32 -07:00

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 };
}