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 savedInnerWidth: number;
|
||||||
let savedMaxTouchPoints: number;
|
let savedMaxTouchPoints: number;
|
||||||
let savedOntouchstart: typeof window.ontouchstart;
|
let savedOntouchstart: typeof window.ontouchstart;
|
||||||
|
let savedUserAgent: string;
|
||||||
let scrollSpy: ReturnType<typeof vi.fn>;
|
let scrollSpy: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -13,6 +14,7 @@ describe("useMobileScrollLock", () => {
|
|||||||
savedInnerWidth = window.innerWidth;
|
savedInnerWidth = window.innerWidth;
|
||||||
savedMaxTouchPoints = navigator.maxTouchPoints;
|
savedMaxTouchPoints = navigator.maxTouchPoints;
|
||||||
savedOntouchstart = window.ontouchstart;
|
savedOntouchstart = window.ontouchstart;
|
||||||
|
savedUserAgent = navigator.userAgent;
|
||||||
document.documentElement.style.cssText = "";
|
document.documentElement.style.cssText = "";
|
||||||
document.body.style.cssText = "";
|
document.body.style.cssText = "";
|
||||||
scrollSpy = vi.fn();
|
scrollSpy = vi.fn();
|
||||||
@@ -24,6 +26,7 @@ describe("useMobileScrollLock", () => {
|
|||||||
Object.defineProperty(window, "innerWidth", { value: savedInnerWidth, writable: true, configurable: true });
|
Object.defineProperty(window, "innerWidth", { value: savedInnerWidth, writable: true, configurable: true });
|
||||||
Object.defineProperty(navigator, "maxTouchPoints", { value: savedMaxTouchPoints, configurable: true });
|
Object.defineProperty(navigator, "maxTouchPoints", { value: savedMaxTouchPoints, configurable: true });
|
||||||
Object.defineProperty(window, "ontouchstart", { value: savedOntouchstart, writable: true, 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.documentElement.style.cssText = "";
|
||||||
document.body.style.cssText = "";
|
document.body.style.cssText = "";
|
||||||
_resetLockState();
|
_resetLockState();
|
||||||
@@ -33,6 +36,21 @@ describe("useMobileScrollLock", () => {
|
|||||||
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
||||||
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
|
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
|
||||||
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, 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() {
|
function makeDesktop() {
|
||||||
@@ -60,6 +78,14 @@ describe("useMobileScrollLock", () => {
|
|||||||
expect(scrollSpy).not.toHaveBeenCalled();
|
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", () => {
|
it("restores prior styles and scroll position on cleanup", () => {
|
||||||
makeMobile();
|
makeMobile();
|
||||||
document.body.style.position = "relative";
|
document.body.style.position = "relative";
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ function isMobileDevice(): boolean {
|
|||||||
return hasTouchScreen && isNarrow;
|
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.
|
* Reference-counted body scroll lock for fullscreen mobile overlays.
|
||||||
*
|
*
|
||||||
@@ -111,7 +132,7 @@ export function _resetLockState(): void {
|
|||||||
*/
|
*/
|
||||||
export function useMobileScrollLock(enabled: boolean): void {
|
export function useMobileScrollLock(enabled: boolean): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !isMobileDevice()) return;
|
if (!enabled || !isMobileDevice() || !isIOS()) return;
|
||||||
applyLock();
|
applyLock();
|
||||||
return () => {
|
return () => {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
|
|||||||
Reference in New Issue
Block a user