fix(dashboard): treat keyboard as closed on blur instead of waiting for vv settle

useMobileKeyboard now requires a focused input for keyboardOpen=true (both
the chrome-overlap and iOS-gap paths). The moment an input blurs, the hook
reports keyboardOpen=false instead of waiting hundreds of ms for iOS's
visualViewport dismissal animation to settle.

This makes App-level mobileKeyboardOpen flip false instantly on blur, so
MobileNavBar reappears and project-content regains nav-bar padding in the
same frame. The ChatView composer (and TodoModal/PlanningModeModal) snap
to their post-keyboard layout in one move instead of crawling down with
iOS's keyboard slide.

Replaces the per-component 450ms suppress hack in ChatView (also dropped
in this commit) which couldn't reach the parent layout's nav padding
state and produced "below tab bar then snap up" jitter.

Adds a regression test and updates four existing tests that assumed
"vv shrinks → keyboard up" without focus (focus is now required).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-03 19:34:28 -07:00
parent 4bb3af220e
commit 330541049d
4 changed files with 85 additions and 51 deletions

View File

@@ -122,6 +122,10 @@ describe("useMobileKeyboard", () => {
vvHeight: 600,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
@@ -148,6 +152,8 @@ describe("useMobileKeyboard", () => {
expect(result.current.keyboardOverlap).toBe(100);
expect(result.current.viewportHeight).toBe(700);
});
input.remove();
});
it("unsubscribes listeners and resets state when disabled", async () => {
@@ -156,6 +162,10 @@ describe("useMobileKeyboard", () => {
vvHeight: 600,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result, rerender } = renderHook(
({ enabled }) => useMobileKeyboard({ enabled }),
{ initialProps: { enabled: true } },
@@ -178,6 +188,8 @@ describe("useMobileKeyboard", () => {
expect(mockVV.removeEventListener).toHaveBeenCalledWith("resize", resizeListener);
expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollListener);
input.remove();
});
it("uses iOS Safari fallback when innerHeight shrinks with visualViewport", async () => {
@@ -186,6 +198,10 @@ describe("useMobileKeyboard", () => {
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
@@ -212,6 +228,8 @@ describe("useMobileKeyboard", () => {
expect(result.current.keyboardOverlap).toBe(324);
expect(result.current.viewportHeight).toBe(520);
});
input.remove();
});
it("reports moderate iOS fallback overlap below 80px", async () => {
@@ -220,6 +238,10 @@ describe("useMobileKeyboard", () => {
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
@@ -245,6 +267,8 @@ describe("useMobileKeyboard", () => {
expect(result.current.keyboardOverlap).toBe(40);
expect(result.current.viewportHeight).toBe(804);
});
input.remove();
});
it("uses focused-input fallback for small viewport gaps", async () => {
@@ -336,6 +360,50 @@ describe("useMobileKeyboard", () => {
input.remove();
});
it("reports keyboardOpen=false the instant focus leaves an input even while visualViewport still reports keyboard-up size", async () => {
// Regression for the ChatView "composer crawls down with the keyboard"
// bug: on iOS the visualViewport keeps reporting the small mid-dismiss
// size for hundreds of ms after the user blurs an input. App-level
// layout (mobile nav bar, project-content padding) must flip back to
// no-keyboard mode immediately on blur, not when vv finally settles.
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
// Bring up the keyboard: focus the input, then shrink the viewport.
input.focus();
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
});
// Blur, but leave visualViewport still reporting the small mid-dismiss
// size — the dismissal animation takes hundreds of ms on iOS.
input.blur();
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
});
input.remove();
});
// FN-3290 regression: focusout must reset keyboard state when input blurs
describe("FN-3290: focusout resets keyboard state", () => {
it("resets keyboardOpen to false on focusout when viewport returns to baseline", async () => {