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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user