fix(dashboard): mobile keyboard scroll lock, auto-reload hash, ChatView snap
- Add useMobileScrollLock hook using position:fixed body lock (Bootstrap/ Headless UI/Stripe pattern) to prevent iOS Safari from shifting the document and visualViewport when an input inside a fixed-position modal is focused. Wire into 15 input-bearing modals plus ChatView, replacing ChatView's inline body-overflow effect. - Widen computeBuildVersion in vite.config.ts to hash the entire app/ tree so the version-check poll actually notices rebuilds (FN-3333 follow-up; previously only main.tsx and package.json were hashed). - ChatView: on input blur, suppress keyboard-aware sizing for 450ms while reserving mobile-nav-bar space, so the composer snaps to its final height in one move instead of crawling down with iOS's keyboard slide and then jumping again when the nav bar reappears. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
112
packages/dashboard/app/hooks/useMobileScrollLock.ts
Normal file
112
packages/dashboard/app/hooks/useMobileScrollLock.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference-counted body scroll lock for fullscreen mobile overlays.
|
||||
*
|
||||
* Uses the `position: fixed; top: -scrollY` pattern (the same approach used
|
||||
* by Bootstrap, Headless UI, and Stripe Elements) instead of just
|
||||
* `overflow: hidden`. The reason: iOS Safari ignores `overflow: hidden` when
|
||||
* an input inside a `position: fixed` overlay is focused — it scrolls the
|
||||
* document to bring the caret above the soft keyboard, and after dismissal
|
||||
* the document can be left scrolled with `visualViewport.offsetTop > 0`,
|
||||
* shoving the underlying dashboard (header included) off the top of the
|
||||
* screen with a matching gap at the bottom.
|
||||
*
|
||||
* Pinning `body` with `position: fixed` makes the document genuinely
|
||||
* unscrollable, so iOS has nothing to do on focus and leaves the visible
|
||||
* area aligned with the layout viewport.
|
||||
*
|
||||
* Reference counting matters because multiple overlays can be open at once
|
||||
* (e.g. a confirm dialog over a TodoModal) — only the outermost lock should
|
||||
* actually mutate styles, so an inner unmount doesn't release the lock for
|
||||
* an outer overlay that is still open.
|
||||
*/
|
||||
let lockCount = 0;
|
||||
let savedStyles: {
|
||||
htmlOverflow: string;
|
||||
bodyPosition: string;
|
||||
bodyTop: string;
|
||||
bodyLeft: string;
|
||||
bodyRight: string;
|
||||
bodyWidth: string;
|
||||
bodyOverflow: string;
|
||||
scrollY: number;
|
||||
} | null = null;
|
||||
|
||||
function applyLock(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (lockCount > 0) {
|
||||
lockCount += 1;
|
||||
return;
|
||||
}
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
savedStyles = {
|
||||
htmlOverflow: html.style.overflow,
|
||||
bodyPosition: body.style.position,
|
||||
bodyTop: body.style.top,
|
||||
bodyLeft: body.style.left,
|
||||
bodyRight: body.style.right,
|
||||
bodyWidth: body.style.width,
|
||||
bodyOverflow: body.style.overflow,
|
||||
scrollY: window.scrollY,
|
||||
};
|
||||
html.style.overflow = "hidden";
|
||||
body.style.position = "fixed";
|
||||
body.style.top = `-${savedStyles.scrollY}px`;
|
||||
body.style.left = "0";
|
||||
body.style.right = "0";
|
||||
body.style.width = "100%";
|
||||
body.style.overflow = "hidden";
|
||||
lockCount = 1;
|
||||
}
|
||||
|
||||
function releaseLock(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (lockCount === 0) return;
|
||||
lockCount -= 1;
|
||||
if (lockCount > 0 || !savedStyles) return;
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const { htmlOverflow, bodyPosition, bodyTop, bodyLeft, bodyRight, bodyWidth, bodyOverflow, scrollY } = savedStyles;
|
||||
html.style.overflow = htmlOverflow;
|
||||
body.style.position = bodyPosition;
|
||||
body.style.top = bodyTop;
|
||||
body.style.left = bodyLeft;
|
||||
body.style.right = bodyRight;
|
||||
body.style.width = bodyWidth;
|
||||
body.style.overflow = bodyOverflow;
|
||||
savedStyles = null;
|
||||
// Snap back to where the user was before the lock started. With the body
|
||||
// un-fixed, this scroll actually applies (vs. being a no-op while the
|
||||
// body was overflow:hidden).
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
|
||||
/** Test-only: reset the module-level lock state. */
|
||||
export function _resetLockState(): void {
|
||||
lockCount = 0;
|
||||
savedStyles = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock body scroll and pin position while a fullscreen mobile overlay is
|
||||
* open. Recovers iOS visualViewport drift on cleanup. No-op on desktop.
|
||||
*/
|
||||
export function useMobileScrollLock(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled || !isMobileDevice()) return;
|
||||
applyLock();
|
||||
return () => {
|
||||
releaseLock();
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user