fix(dashboard): gate mobile scroll-lock to iOS so Android keyboard stays open
The body scroll-lock applied while the keyboard is up in main chat was an iOS-specific workaround for visualViewport drift. On Android Chrome the same mutation does the opposite of what we want — applying position:fixed to body while the soft keyboard is opening causes Chrome to treat it as a focus- target relayout and dismiss the keyboard instantly, making the main chat composer unusable on Android. useMobileScrollLock now early-returns on non-iOS user agents. Android Chrome doesn't need it: with interactive-widget=resizes-content the layout viewport shrinks with the keyboard, so there's no drift to compensate for. Adds an Android-UA test case that asserts the lock is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
.changeset/android-chat-keyboard-dismiss.md
Normal file
9
.changeset/android-chat-keyboard-dismiss.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@fusion/dashboard": patch
|
||||
---
|
||||
|
||||
fix(dashboard): stop ChatView's body scroll-lock from instantly dismissing the Android soft keyboard
|
||||
|
||||
The body scroll-lock applied while the keyboard is open in main chat was an iOS-specific workaround for visualViewport drift. On Android Chrome it does the opposite of what we want — mutating `body { position: fixed; ... }` while the keyboard is opening causes Chrome to treat it as a focus-target relayout and immediately dismisses the keyboard, making the main chat composer unusable on Android.
|
||||
|
||||
`useMobileScrollLock` is now gated to iOS UAs. Android Chrome doesn't need it (with `interactive-widget=resizes-content` the layout viewport shrinks with the keyboard, so no drift compensation is required).
|
||||
@@ -6,6 +6,7 @@ describe("useMobileScrollLock", () => {
|
||||
let savedInnerWidth: number;
|
||||
let savedMaxTouchPoints: number;
|
||||
let savedOntouchstart: typeof window.ontouchstart;
|
||||
let savedUserAgent: string;
|
||||
let scrollSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -13,6 +14,7 @@ describe("useMobileScrollLock", () => {
|
||||
savedInnerWidth = window.innerWidth;
|
||||
savedMaxTouchPoints = navigator.maxTouchPoints;
|
||||
savedOntouchstart = window.ontouchstart;
|
||||
savedUserAgent = navigator.userAgent;
|
||||
document.documentElement.style.cssText = "";
|
||||
document.body.style.cssText = "";
|
||||
scrollSpy = vi.fn();
|
||||
@@ -24,6 +26,7 @@ describe("useMobileScrollLock", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: savedInnerWidth, writable: true, configurable: true });
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { value: savedMaxTouchPoints, configurable: true });
|
||||
Object.defineProperty(window, "ontouchstart", { value: savedOntouchstart, writable: true, configurable: true });
|
||||
Object.defineProperty(navigator, "userAgent", { value: savedUserAgent, configurable: true });
|
||||
document.documentElement.style.cssText = "";
|
||||
document.body.style.cssText = "";
|
||||
_resetLockState();
|
||||
@@ -33,6 +36,21 @@ describe("useMobileScrollLock", () => {
|
||||
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
|
||||
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
|
||||
// Hook is iOS-gated; default fixture uses an iPhone UA.
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
value: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeAndroid() {
|
||||
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
|
||||
Object.defineProperty(window, "innerWidth", { value: 388, writable: true, configurable: true });
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
value: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36",
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeDesktop() {
|
||||
@@ -60,6 +78,14 @@ describe("useMobileScrollLock", () => {
|
||||
expect(scrollSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing on Android (avoids dismissing the soft keyboard)", () => {
|
||||
makeAndroid();
|
||||
renderHook(() => useMobileScrollLock(true));
|
||||
expect(document.body.style.position).toBe("");
|
||||
expect(document.documentElement.style.overflow).toBe("");
|
||||
expect(scrollSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores prior styles and scroll position on cleanup", () => {
|
||||
makeMobile();
|
||||
document.body.style.position = "relative";
|
||||
|
||||
@@ -8,6 +8,27 @@ function isMobileDevice(): boolean {
|
||||
return hasTouchScreen && isNarrow;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroll lock is an iOS-specific workaround: iOS Safari shifts the layout
|
||||
* viewport on input focus (visualViewport.offsetTop > 0) which pushes the
|
||||
* dashboard off-screen, so we pin body via position:fixed to make it
|
||||
* unscrollable. Android Chrome does NOT need this — and applying the same
|
||||
* fix there is actively harmful: mutating body styles while the soft keyboard
|
||||
* is opening causes Chrome to treat it as a focus-target relayout and
|
||||
* dismisses the keyboard immediately. So we gate the lock to iOS only.
|
||||
*
|
||||
* With `interactive-widget=resizes-content` set on the viewport meta, Android
|
||||
* Chrome shrinks the layout viewport with the keyboard, so no drift
|
||||
* compensation is needed there.
|
||||
*/
|
||||
function isIOS(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const ua = navigator.userAgent || "";
|
||||
// iPad on iPadOS 13+ reports as MacIntel + touch — handle that too.
|
||||
return /iPad|iPhone|iPod/.test(ua)
|
||||
|| (ua.includes("Macintosh") && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference-counted body scroll lock for fullscreen mobile overlays.
|
||||
*
|
||||
@@ -111,7 +132,7 @@ export function _resetLockState(): void {
|
||||
*/
|
||||
export function useMobileScrollLock(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled || !isMobileDevice()) return;
|
||||
if (!enabled || !isMobileDevice() || !isIOS()) return;
|
||||
applyLock();
|
||||
return () => {
|
||||
releaseLock();
|
||||
|
||||
Reference in New Issue
Block a user